baubs git
Commit a253901fb4e6a7674c3264c5ab2750b647dbaa34
Parents : 20b8d47
Author : Jan-Henrik Bruhn <jan-henrik.bruhn@offis.de>
Date : 2025-12-18T11:39:22+01:00
fix: run linter
Changes
44 files changed, 3253 insertions(+), 2530 deletions(-)
Diff
diff --git a/electron/preload.ts b/electron/preload.ts
index b24fb26..84ce05d 100644
--- a/electron/preload.ts
+++ b/electron/preload.ts
@@ -1,19 +1,19 @@
-import { contextBridge, ipcRenderer } from 'electron';
+import { contextBridge, ipcRenderer } from "electron";
// Expose protected methods that allow the renderer process to use
// ipcRenderer without exposing the entire object
-contextBridge.exposeInMainWorld('electronAPI', {
+contextBridge.exposeInMainWorld("electronAPI", {
invoke: (channel: string, ...args: unknown[]) => {
const validChannels = [
- 'storage:savePattern',
- 'storage:getPattern',
- 'storage:getLatest',
- 'storage:deletePattern',
- 'storage:clear',
- 'dialog:openFile',
- 'dialog:saveFile',
- 'fs:readFile',
- 'fs:writeFile',
+ "storage:savePattern",
+ "storage:getPattern",
+ "storage:getLatest",
+ "storage:deletePattern",
+ "storage:clear",
+ "dialog:openFile",
+ "dialog:saveFile",
+ "fs:readFile",
+ "fs:writeFile",
];
if (validChannels.includes(channel)) {
@@ -23,15 +23,21 @@ contextBridge.exposeInMainWorld('electronAPI', {
throw new Error(`Invalid IPC channel: ${channel}`);
},
// Bluetooth device selection
- onBluetoothDeviceList: (callback: (devices: Array<{ deviceId: string; deviceName: string }>) => void) => {
- ipcRenderer.on('bluetooth:device-list', (_event, devices) => callback(devices));
+ onBluetoothDeviceList: (
+ callback: (
+ devices: Array<{ deviceId: string; deviceName: string }>,
+ ) => void,
+ ) => {
+ ipcRenderer.on("bluetooth:device-list", (_event, devices) =>
+ callback(devices),
+ );
},
selectBluetoothDevice: (deviceId: string) => {
- ipcRenderer.send('bluetooth:select-device', deviceId);
+ ipcRenderer.send("bluetooth:select-device", deviceId);
},
});
// Also expose process type for platform detection
-contextBridge.exposeInMainWorld('process', {
- type: 'renderer',
+contextBridge.exposeInMainWorld("process", {
+ type: "renderer",
});
diff --git a/src/App.tsx b/src/App.tsx
index 9a6f49c..4c94540 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,14 +1,14 @@
-import { useEffect } from 'react';
-import { useShallow } from 'zustand/react/shallow';
-import { useMachineStore } from './stores/useMachineStore';
-import { usePatternStore } from './stores/usePatternStore';
-import { useUIStore } from './stores/useUIStore';
-import { AppHeader } from './components/AppHeader';
-import { LeftSidebar } from './components/LeftSidebar';
-import { PatternCanvas } from './components/PatternCanvas';
-import { PatternPreviewPlaceholder } from './components/PatternPreviewPlaceholder';
-import { BluetoothDevicePicker } from './components/BluetoothDevicePicker';
-import './App.css';
+import { useEffect } from "react";
+import { useShallow } from "zustand/react/shallow";
+import { useMachineStore } from "./stores/useMachineStore";
+import { usePatternStore } from "./stores/usePatternStore";
+import { useUIStore } from "./stores/useUIStore";
+import { AppHeader } from "./components/AppHeader";
+import { LeftSidebar } from "./components/LeftSidebar";
+import { PatternCanvas } from "./components/PatternCanvas";
+import { PatternPreviewPlaceholder } from "./components/PatternPreviewPlaceholder";
+import { BluetoothDevicePicker } from "./components/BluetoothDevicePicker";
+import "./App.css";
function App() {
// Set page title with version
@@ -17,36 +17,27 @@ function App() {
}, []);
// Machine store - for auto-loading cached pattern
- const {
- resumedPattern,
- resumeFileName,
- } = useMachineStore(
+ const { resumedPattern, resumeFileName } = useMachineStore(
useShallow((state) => ({
resumedPattern: state.resumedPattern,
resumeFileName: state.resumeFileName,
- }))
+ })),
);
// Pattern store - for auto-loading cached pattern
- const {
- pesData,
- setPattern,
- setPatternOffset,
- } = usePatternStore(
+ const { pesData, setPattern, setPatternOffset } = usePatternStore(
useShallow((state) => ({
pesData: state.pesData,
setPattern: state.setPattern,
setPatternOffset: state.setPatternOffset,
- }))
+ })),
);
// UI store - for Pyodide initialization
- const {
- initializePyodide,
- } = useUIStore(
+ const { initializePyodide } = useUIStore(
useShallow((state) => ({
initializePyodide: state.initializePyodide,
- }))
+ })),
);
// Initialize Pyodide in background on mount (non-blocking thanks to worker)
@@ -57,11 +48,19 @@ function App() {
// Auto-load cached pattern when available
useEffect(() => {
if (resumedPattern && !pesData) {
- console.log('[App] Loading resumed pattern:', resumeFileName, 'Offset:', resumedPattern.patternOffset);
- setPattern(resumedPattern.pesData, resumeFileName || '');
+ console.log(
+ "[App] Loading resumed pattern:",
+ resumeFileName,
+ "Offset:",
+ resumedPattern.patternOffset,
+ );
+ setPattern(resumedPattern.pesData, resumeFileName || "");
// Restore the cached pattern offset
if (resumedPattern.patternOffset) {
- setPatternOffset(resumedPattern.patternOffset.x, resumedPattern.patternOffset.y);
+ setPatternOffset(
+ resumedPattern.patternOffset.x,
+ resumedPattern.patternOffset.y,
+ );
}
}
}, [resumedPattern, resumeFileName, pesData, setPattern, setPatternOffset]);
diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx
index 1ffacad..c5f736d 100644
--- a/src/components/AppHeader.tsx
+++ b/src/components/AppHeader.tsx
@@ -1,10 +1,10 @@
-import { useRef, useEffect } from 'react';
-import { useShallow } from 'zustand/react/shallow';
-import { useMachineStore } from '../stores/useMachineStore';
-import { useUIStore } from '../stores/useUIStore';
-import { WorkflowStepper } from './WorkflowStepper';
-import { ErrorPopover } from './ErrorPopover';
-import { getStateVisualInfo } from '../utils/machineStateHelpers';
+import { useRef, useEffect } from "react";
+import { useShallow } from "zustand/react/shallow";
+import { useMachineStore } from "../stores/useMachineStore";
+import { useUIStore } from "../stores/useUIStore";
+import { WorkflowStepper } from "./WorkflowStepper";
+import { ErrorPopover } from "./ErrorPopover";
+import { getStateVisualInfo } from "../utils/machineStateHelpers";
import {
CheckCircleIcon,
BoltIcon,
@@ -12,7 +12,7 @@ import {
ExclamationTriangleIcon,
ArrowPathIcon,
XMarkIcon,
-} from '@heroicons/react/24/solid';
+} from "@heroicons/react/24/solid";
export function AppHeader() {
const {
@@ -36,19 +36,15 @@ export function AppHeader() {
isPairingError: state.isPairingError,
isCommunicating: state.isCommunicating,
disconnect: state.disconnect,
- }))
+ })),
);
- const {
- pyodideError,
- showErrorPopover,
- setErrorPopover,
- } = useUIStore(
+ const { pyodideError, showErrorPopover, setErrorPopover } = useUIStore(
useShallow((state) => ({
pyodideError: state.pyodideError,
showErrorPopover: state.showErrorPopover,
setErrorPopover: state.setErrorPopover,
- }))
+ })),
);
const errorPopoverRef = useRef<HTMLDivElement>(null);
@@ -80,8 +76,9 @@ export function AppHeader() {
};
if (showErrorPopover) {
- document.addEventListener('mousedown', handleClickOutside);
- return () => document.removeEventListener('mousedown', handleClickOutside);
+ document.addEventListener("mousedown", handleClickOutside);
+ return () =>
+ document.removeEventListener("mousedown", handleClickOutside);
}
}, [showErrorPopover, setErrorPopover]);
@@ -90,33 +87,44 @@ export function AppHeader() {
<div className="grid grid-cols-1 lg:grid-cols-[280px_1fr] gap-4 lg:gap-8 items-center">
{/* Machine Connection Status - Responsive width column */}
<div className="flex items-center gap-3 w-full lg:w-[280px]">
- <div className="w-2.5 h-2.5 bg-success-400 rounded-full animate-pulse shadow-lg shadow-success-400/50" style={{ visibility: isConnected ? 'visible' : 'hidden' }}></div>
- <div className="w-2.5 h-2.5 bg-gray-400 rounded-full -ml-2.5" style={{ visibility: !isConnected ? 'visible' : 'hidden' }}></div>
+ <div
+ className="w-2.5 h-2.5 bg-success-400 rounded-full animate-pulse shadow-lg shadow-success-400/50"
+ style={{ visibility: isConnected ? "visible" : "hidden" }}
+ ></div>
+ <div
+ className="w-2.5 h-2.5 bg-gray-400 rounded-full -ml-2.5"
+ style={{ visibility: !isConnected ? "visible" : "hidden" }}
+ ></div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
- <h1 className="text-lg lg:text-xl font-bold text-white leading-tight">Respira</h1>
+ <h1 className="text-lg lg:text-xl font-bold text-white leading-tight">
+ Respira
+ </h1>
{isConnected && machineInfo?.serialNumber && (
<span
className="text-xs text-primary-200 cursor-help"
title={`Serial: ${machineInfo.serialNumber}${
machineInfo.macAddress
? `\nMAC: ${machineInfo.macAddress}`
- : ''
+ : ""
}${
machineInfo.totalCount !== undefined
? `\nTotal stitches: ${machineInfo.totalCount.toLocaleString()}`
- : ''
+ : ""
}${
machineInfo.serviceCount !== undefined
? `\nStitches since service: ${machineInfo.serviceCount.toLocaleString()}`
- : ''
+ : ""
}`}
>
• {machineInfo.serialNumber}
</span>
)}
{isPolling && (
- <ArrowPathIcon className="w-3.5 h-3.5 text-primary-200 animate-spin" title="Auto-refreshing status" />
+ <ArrowPathIcon
+ className="w-3.5 h-3.5 text-primary-200 animate-spin"
+ title="Auto-refreshing status"
+ />
)}
</div>
<div className="flex items-center gap-2 mt-1 min-h-[32px]">
@@ -146,9 +154,9 @@ export function AppHeader() {
ref={errorButtonRef}
onClick={() => setErrorPopover(!showErrorPopover)}
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 sm:py-1 rounded text-sm font-medium bg-danger-500/90 hover:bg-danger-600 text-white border border-danger-400 transition-all flex-shrink-0 ${
- (machineErrorMessage || pyodideError)
- ? 'cursor-pointer animate-pulse hover:animate-none'
- : 'invisible pointer-events-none'
+ machineErrorMessage || pyodideError
+ ? "cursor-pointer animate-pulse hover:animate-none"
+ : "invisible pointer-events-none"
}`}
title="Click to view error details"
aria-label="View error details"
@@ -157,27 +165,30 @@ export function AppHeader() {
<ExclamationTriangleIcon className="w-3.5 h-3.5 flex-shrink-0" />
<span>
{(() => {
- if (pyodideError) return 'Python Error';
- if (isPairingError) return 'Pairing Required';
+ if (pyodideError) return "Python Error";
+ if (isPairingError) return "Pairing Required";
- const errorMsg = machineErrorMessage || '';
+ const errorMsg = machineErrorMessage || "";
// Categorize by error message content
- if (errorMsg.toLowerCase().includes('bluetooth') || errorMsg.toLowerCase().includes('connection')) {
- return 'Connection Error';
+ if (
+ errorMsg.toLowerCase().includes("bluetooth") ||
+ errorMsg.toLowerCase().includes("connection")
+ ) {
+ return "Connection Error";
}
- if (errorMsg.toLowerCase().includes('upload')) {
- return 'Upload Error';
+ if (errorMsg.toLowerCase().includes("upload")) {
+ return "Upload Error";
}
- if (errorMsg.toLowerCase().includes('pattern')) {
- return 'Pattern Error';
+ if (errorMsg.toLowerCase().includes("pattern")) {
+ return "Pattern Error";
}
if (machineError !== undefined) {
return `Machine Error`;
}
// Default fallback
- return 'Error';
+ return "Error";
})()}
</span>
</button>
diff --git a/src/components/BluetoothDevicePicker.tsx b/src/components/BluetoothDevicePicker.tsx
index c993423..b828d03 100644
--- a/src/components/BluetoothDevicePicker.tsx
+++ b/src/components/BluetoothDevicePicker.tsx
@@ -1,5 +1,5 @@
-import { useEffect, useState, useCallback } from 'react';
-import type { BluetoothDevice } from '../types/electron';
+import { useEffect, useState, useCallback } from "react";
+import type { BluetoothDevice } from "../types/electron";
export function BluetoothDevicePicker() {
const [devices, setDevices] = useState<BluetoothDevice[]>([]);
@@ -10,7 +10,7 @@ export function BluetoothDevicePicker() {
// Only set up listener in Electron
if (window.electronAPI?.onBluetoothDeviceList) {
window.electronAPI.onBluetoothDeviceList((deviceList) => {
- console.log('[BluetoothPicker] Received device list:', deviceList);
+ console.log("[BluetoothPicker] Received device list:", deviceList);
setDevices(deviceList);
// Open the picker when scan starts (even if empty at first)
if (!isOpen) {
@@ -26,38 +26,44 @@ export function BluetoothDevicePicker() {
}, [isOpen]);
const handleSelectDevice = useCallback((deviceId: string) => {
- console.log('[BluetoothPicker] User selected device:', deviceId);
+ console.log("[BluetoothPicker] User selected device:", deviceId);
window.electronAPI?.selectBluetoothDevice(deviceId);
setIsOpen(false);
setDevices([]);
}, []);
const handleCancel = useCallback(() => {
- console.log('[BluetoothPicker] User cancelled device selection');
- window.electronAPI?.selectBluetoothDevice('');
+ console.log("[BluetoothPicker] User cancelled device selection");
+ window.electronAPI?.selectBluetoothDevice("");
setIsOpen(false);
setDevices([]);
setIsScanning(false);
}, []);
// Handle escape key
- const handleEscape = useCallback((e: KeyboardEvent) => {
- if (e.key === 'Escape') {
- handleCancel();
- }
- }, [handleCancel]);
+ const handleEscape = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ handleCancel();
+ }
+ },
+ [handleCancel],
+ );
useEffect(() => {
if (isOpen) {
- document.addEventListener('keydown', handleEscape);
- return () => document.removeEventListener('keydown', handleEscape);
+ document.addEventListener("keydown", handleEscape);
+ return () => document.removeEventListener("keydown", handleEscape);
}
}, [isOpen, handleEscape]);
if (!isOpen) return null;
return (
- <div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]" onClick={handleCancel}>
+ <div
+ className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]"
+ onClick={handleCancel}
+ >
<div
className="bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-lg w-[90%] m-4 border-t-4 border-primary-600 dark:border-primary-500"
onClick={(e) => e.stopPropagation()}
@@ -66,23 +72,48 @@ export function BluetoothDevicePicker() {
aria-describedby="bluetooth-picker-message"
>
<div className="p-6 border-b border-gray-300 dark:border-gray-600">
- <h3 id="bluetooth-picker-title" className="m-0 text-base lg:text-lg font-semibold dark:text-white">
+ <h3
+ id="bluetooth-picker-title"
+ className="m-0 text-base lg:text-lg font-semibold dark:text-white"
+ >
Select Bluetooth Device
</h3>
</div>
<div className="p-6">
{isScanning && devices.length === 0 ? (
<div className="flex items-center gap-3 text-gray-700 dark:text-gray-300">
- <svg className="animate-spin h-5 w-5 text-primary-600 dark:text-primary-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+ <svg
+ className="animate-spin h-5 w-5 text-primary-600 dark:text-primary-400"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ >
+ <circle
+ className="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ strokeWidth="4"
+ ></circle>
+ <path
+ className="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+ ></path>
</svg>
- <span id="bluetooth-picker-message">Scanning for Bluetooth devices...</span>
+ <span id="bluetooth-picker-message">
+ Scanning for Bluetooth devices...
+ </span>
</div>
) : (
<>
- <p id="bluetooth-picker-message" className="mb-4 leading-relaxed text-gray-900 dark:text-gray-100">
- {devices.length} device{devices.length !== 1 ? 's' : ''} found. Select a device to connect:
+ <p
+ id="bluetooth-picker-message"
+ className="mb-4 leading-relaxed text-gray-900 dark:text-gray-100"
+ >
+ {devices.length} device{devices.length !== 1 ? "s" : ""} found.
+ Select a device to connect:
</p>
<div className="space-y-2">
{devices.map((device) => (
@@ -92,8 +123,12 @@ export function BluetoothDevicePicker() {
className="w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 text-left rounded-lg font-medium text-sm hover:bg-primary-100 dark:hover:bg-primary-900 hover:text-primary-900 dark:hover:text-primary-100 active:bg-primary-200 dark:active:bg-primary-800 transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
aria-label={`Connect to ${device.deviceName}`}
>
- <div className="font-semibold text-gray-900 dark:text-white">{device.deviceName}</div>
- <div className="text-xs text-gray-600 dark:text-gray-400 mt-1">{device.deviceId}</div>
+ <div className="font-semibold text-gray-900 dark:text-white">
+ {device.deviceName}
+ </div>
+ <div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
+ {device.deviceId}
+ </div>
</button>
))}
</div>
diff --git a/src/components/ConfirmDialog.tsx b/src/components/ConfirmDialog.tsx
index 68cea07..d984cdc 100644
--- a/src/components/ConfirmDialog.tsx
+++ b/src/components/ConfirmDialog.tsx
@@ -1,79 +1,95 @@
-import { useEffect, useCallback } from 'react';
-
-interface ConfirmDialogProps {
- isOpen: boolean;
- title: string;
- message: string;
- confirmText?: string;
- cancelText?: string;
- onConfirm: () => void;
- onCancel: () => void;
- variant?: 'danger' | 'warning';
-}
-
-export function ConfirmDialog({
- isOpen,
- title,
- message,
- confirmText = 'Confirm',
- cancelText = 'Cancel',
- onConfirm,
- onCancel,
- variant = 'warning',
-}: ConfirmDialogProps) {
- // Handle escape key
- const handleEscape = useCallback((e: KeyboardEvent) => {
- if (e.key === 'Escape') {
- onCancel();
- }
- }, [onCancel]);
-
- useEffect(() => {
- if (isOpen) {
- document.addEventListener('keydown', handleEscape);
- return () => document.removeEventListener('keydown', handleEscape);
- }
- }, [isOpen, handleEscape]);
-
- if (!isOpen) return null;
-
- return (
- <div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]" onClick={onCancel}>
- <div
- className={`bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-lg w-[90%] m-4 ${variant === 'danger' ? 'border-t-4 border-danger-600 dark:border-danger-500' : 'border-t-4 border-warning-500 dark:border-warning-600'}`}
- onClick={(e) => e.stopPropagation()}
- role="dialog"
- aria-labelledby="dialog-title"
- aria-describedby="dialog-message"
- >
- <div className="p-6 border-b border-gray-300 dark:border-gray-600">
- <h3 id="dialog-title" className="m-0 text-base lg:text-lg font-semibold dark:text-white">{title}</h3>
- </div>
- <div className="p-6">
- <p id="dialog-message" className="m-0 leading-relaxed text-gray-900 dark:text-gray-100">{message}</p>
- </div>
- <div className="p-4 px-6 flex gap-3 justify-end border-t border-gray-300 dark:border-gray-600">
- <button
- onClick={onCancel}
- className="px-6 py-2.5 bg-gray-600 dark:bg-gray-700 text-white rounded-lg font-semibold text-sm hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-gray-300 dark:focus:ring-gray-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
- autoFocus
- aria-label="Cancel action"
- >
- {cancelText}
- </button>
- <button
- onClick={onConfirm}
- className={
- variant === 'danger'
- ? 'px-6 py-2.5 bg-danger-600 dark:bg-danger-700 text-white rounded-lg font-semibold text-sm hover:bg-danger-700 dark:hover:bg-danger-600 active:bg-danger-800 dark:active:bg-danger-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-danger-300 dark:focus:ring-danger-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900'
- : 'px-6 py-2.5 bg-primary-600 dark:bg-primary-700 text-white rounded-lg font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900'
- }
- aria-label={`Confirm: ${confirmText}`}
- >
- {confirmText}
- </button>
- </div>
- </div>
- </div>
- );
-}
+import { useEffect, useCallback } from "react";
+
+interface ConfirmDialogProps {
+ isOpen: boolean;
+ title: string;
+ message: string;
+ confirmText?: string;
+ cancelText?: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+ variant?: "danger" | "warning";
+}
+
+export function ConfirmDialog({
+ isOpen,
+ title,
+ message,
+ confirmText = "Confirm",
+ cancelText = "Cancel",
+ onConfirm,
+ onCancel,
+ variant = "warning",
+}: ConfirmDialogProps) {
+ // Handle escape key
+ const handleEscape = useCallback(
+ (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ onCancel();
+ }
+ },
+ [onCancel],
+ );
+
+ useEffect(() => {
+ if (isOpen) {
+ document.addEventListener("keydown", handleEscape);
+ return () => document.removeEventListener("keydown", handleEscape);
+ }
+ }, [isOpen, handleEscape]);
+
+ if (!isOpen) return null;
+
+ return (
+ <div
+ className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-[1000]"
+ onClick={onCancel}
+ >
+ <div
+ className={`bg-white dark:bg-gray-800 rounded-lg shadow-2xl max-w-lg w-[90%] m-4 ${variant === "danger" ? "border-t-4 border-danger-600 dark:border-danger-500" : "border-t-4 border-warning-500 dark:border-warning-600"}`}
+ onClick={(e) => e.stopPropagation()}
+ role="dialog"
+ aria-labelledby="dialog-title"
+ aria-describedby="dialog-message"
+ >
+ <div className="p-6 border-b border-gray-300 dark:border-gray-600">
+ <h3
+ id="dialog-title"
+ className="m-0 text-base lg:text-lg font-semibold dark:text-white"
+ >
+ {title}
+ </h3>
+ </div>
+ <div className="p-6">
+ <p
+ id="dialog-message"
+ className="m-0 leading-relaxed text-gray-900 dark:text-gray-100"
+ >
+ {message}
+ </p>
+ </div>
+ <div className="p-4 px-6 flex gap-3 justify-end border-t border-gray-300 dark:border-gray-600">
+ <button
+ onClick={onCancel}
+ className="px-6 py-2.5 bg-gray-600 dark:bg-gray-700 text-white rounded-lg font-semibold text-sm hover:bg-gray-700 dark:hover:bg-gray-600 active:bg-gray-800 dark:active:bg-gray-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-gray-300 dark:focus:ring-gray-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
+ autoFocus
+ aria-label="Cancel action"
+ >
+ {cancelText}
+ </button>
+ <button
+ onClick={onConfirm}
+ className={
+ variant === "danger"
+ ? "px-6 py-2.5 bg-danger-600 dark:bg-danger-700 text-white rounded-lg font-semibold text-sm hover:bg-danger-700 dark:hover:bg-danger-600 active:bg-danger-800 dark:active:bg-danger-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-danger-300 dark:focus:ring-danger-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
+ : "px-6 py-2.5 bg-primary-600 dark:bg-primary-700 text-white rounded-lg font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 hover:shadow-lg active:scale-[0.98] transition-all duration-150 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-300 dark:focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
+ }
+ aria-label={`Confirm: ${confirmText}`}
+ >
+ {confirmText}
+ </button>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/src/components/ConnectionPrompt.tsx b/src/components/ConnectionPrompt.tsx
index 77bfcb9..2c99ef3 100644
--- a/src/components/ConnectionPrompt.tsx
+++ b/src/components/ConnectionPrompt.tsx
@@ -1,13 +1,13 @@
-import { useShallow } from 'zustand/react/shallow';
-import { useMachineStore } from '../stores/useMachineStore';
-import { isBluetoothSupported } from '../utils/bluetoothSupport';
-import { ExclamationTriangleIcon } from '@heroicons/react/24/solid';
+import { useShallow } from "zustand/react/shallow";
+import { useMachineStore } from "../stores/useMachineStore";
+import { isBluetoothSupported } from "../utils/bluetoothSupport";
+import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
export function ConnectionPrompt() {
const { connect } = useMachineStore(
useShallow((state) => ({
connect: state.connect,
- }))
+ })),
);
if (isBluetoothSupported()) {
@@ -15,13 +15,27 @@ export function ConnectionPrompt() {
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600">
<div className="flex items-start gap-3 mb-3">
<div className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5">
- <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
+ <svg
+ className="w-6 h-6"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"
+ />
</svg>
</div>
<div className="flex-1 min-w-0">
- <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Get Started</h3>
- <p className="text-xs text-gray-600 dark:text-gray-400">Connect to your embroidery machine</p>
+ <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
+ Get Started
+ </h3>
+ <p className="text-xs text-gray-600 dark:text-gray-400">
+ Connect to your embroidery machine
+ </p>
</div>
</div>
<button
@@ -39,16 +53,21 @@ export function ConnectionPrompt() {
<div className="flex items-start gap-3">
<ExclamationTriangleIcon className="w-6 h-6 text-warning-600 dark:text-warning-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
- <h3 className="text-base font-semibold text-warning-900 dark:text-warning-100 mb-2">Browser Not Supported</h3>
+ <h3 className="text-base font-semibold text-warning-900 dark:text-warning-100 mb-2">
+ Browser Not Supported
+ </h3>
<p className="text-sm text-warning-800 dark:text-warning-200 mb-3">
- Your browser doesn't support Web Bluetooth, which is required to connect to your embroidery machine.
+ Your browser doesn't support Web Bluetooth, which is required to
+ connect to your embroidery machine.
</p>
<div className="space-y-2">
- <p className="text-sm font-semibold text-warning-900 dark:text-warning-100">Please try one of these options:</p>
+ <p className="text-sm font-semibold text-warning-900 dark:text-warning-100">
+ Please try one of these options:
+ </p>
<ul className="text-sm text-warning-800 dark:text-warning-200 space-y-1.5 ml-4 list-disc">
<li>Use a supported browser (Chrome, Edge, or Opera)</li>
<li>
- Download the Desktop app from{' '}
+ Download the Desktop app from{" "}
<a
href="https://github.com/jhbruhn/respira/releases/latest"
target="_blank"
diff --git a/src/components/ErrorPopover.tsx b/src/components/ErrorPopover.tsx
index 25e8ebd..fd98541 100644
--- a/src/components/ErrorPopover.tsx
+++ b/src/components/ErrorPopover.tsx
@@ -1,6 +1,9 @@
-import { forwardRef } from 'react';
-import { ExclamationTriangleIcon, InformationCircleIcon } from '@heroicons/react/24/solid';
-import { getErrorDetails } from '../utils/errorCodeHelpers';
+import { forwardRef } from "react";
+import {
+ ExclamationTriangleIcon,
+ InformationCircleIcon,
+} from "@heroicons/react/24/solid";
+import { getErrorDetails } from "../utils/errorCodeHelpers";
interface ErrorPopoverProps {
machineError?: number;
@@ -13,31 +16,32 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
({ machineError, isPairingError, errorMessage, pyodideError }, ref) => {
const errorDetails = getErrorDetails(machineError);
const isPairingErr = isPairingError;
- const errorMsg = pyodideError || errorMessage || '';
+ const errorMsg = pyodideError || errorMessage || "";
const isInfo = isPairingErr || errorDetails?.isInformational;
const bgColor = isInfo
- ? 'bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500'
- : 'bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500';
+ ? "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500"
+ : "bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500";
const iconColor = isInfo
- ? 'text-info-600 dark:text-info-400'
- : 'text-danger-600 dark:text-danger-400';
+ ? "text-info-600 dark:text-info-400"
+ : "text-danger-600 dark:text-danger-400";
const textColor = isInfo
- ? 'text-info-900 dark:text-info-200'
- : 'text-danger-900 dark:text-danger-200';
+ ? "text-info-900 dark:text-info-200"
+ : "text-danger-900 dark:text-danger-200";
const descColor = isInfo
- ? 'text-info-800 dark:text-info-300'
- : 'text-danger-800 dark:text-danger-300';
+ ? "text-info-800 dark:text-info-300"
+ : "text-danger-800 dark:text-danger-300";
const listColor = isInfo
- ? 'text-info-700 dark:text-info-300'
- : 'text-danger-700 dark:text-danger-300';
+ ? "text-info-700 dark:text-info-300"
+ : "text-danger-700 dark:text-danger-300";
const Icon = isInfo ? InformationCircleIcon : ExclamationTriangleIcon;
- const title = errorDetails?.title || (isPairingErr ? 'Pairing Required' : 'Error');
+ const title =
+ errorDetails?.title || (isPairingErr ? "Pairing Required" : "Error");
return (
<div
@@ -46,7 +50,9 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
role="dialog"
aria-label="Error details"
>
- <div className={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}>
+ <div
+ className={`${bgColor} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
+ >
<div className="flex items-start gap-3">
<Icon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
<div className="flex-1">
@@ -59,18 +65,23 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
{errorDetails?.solutions && errorDetails.solutions.length > 0 && (
<>
<h4 className={`text-sm font-semibold ${textColor} mb-2`}>
- {isInfo ? 'Steps:' : 'How to Fix:'}
+ {isInfo ? "Steps:" : "How to Fix:"}
</h4>
- <ol className={`list-decimal list-inside text-sm ${listColor} space-y-1.5`}>
+ <ol
+ className={`list-decimal list-inside text-sm ${listColor} space-y-1.5`}
+ >
{errorDetails.solutions.map((solution, index) => (
- <li key={index} className="pl-2">{solution}</li>
+ <li key={index} className="pl-2">
+ {solution}
+ </li>
))}
</ol>
</>
)}
{machineError !== undefined && !errorDetails?.isInformational && (
<p className={`text-xs ${descColor} mt-3 font-mono`}>
- Error Code: 0x{machineError.toString(16).toUpperCase().padStart(2, '0')}
+ Error Code: 0x
+ {machineError.toString(16).toUpperCase().padStart(2, "0")}
</p>
)}
</div>
@@ -78,7 +89,7 @@ export const ErrorPopover = forwardRef<HTMLDivElement, ErrorPopoverProps>(
</div>
</div>
);
- }
+ },
);
-ErrorPopover.displayName = 'ErrorPopover';
+ErrorPopover.displayName = "ErrorPopover";
diff --git a/src/components/FileUpload.tsx b/src/components/FileUpload.tsx
index 3eedd4e..30ebfc3 100644
--- a/src/components/FileUpload.tsx
+++ b/src/components/FileUpload.tsx
@@ -1,336 +1,430 @@
-import { useState, useCallback } from 'react';
-import { useShallow } from 'zustand/react/shallow';
-import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore';
-import { usePatternStore } from '../stores/usePatternStore';
-import { useUIStore } from '../stores/useUIStore';
-import { convertPesToPen, type PesPatternData } from '../formats/import/pesImporter';
-import { canUploadPattern, getMachineStateCategory } from '../utils/machineStateHelpers';
-import { PatternInfoSkeleton } from './SkeletonLoader';
-import { PatternInfo } from './PatternInfo';
-import { ArrowUpTrayIcon, CheckCircleIcon, DocumentTextIcon, FolderOpenIcon } from '@heroicons/react/24/solid';
-import { createFileService } from '../platform';
-import type { IFileService } from '../platform/interfaces/IFileService';
-
-export function FileUpload() {
- // Machine store
- const {
- isConnected,
- machineStatus,
- uploadProgress,
- isUploading,
- machineInfo,
- resumeAvailable,
- resumeFileName,
- uploadPattern,
- } = useMachineStore(
- useShallow((state) => ({
- isConnected: state.isConnected,
- machineStatus: state.machineStatus,
- uploadProgress: state.uploadProgress,
- isUploading: state.isUploading,
- machineInfo: state.machineInfo,
- resumeAvailable: state.resumeAvailable,
- resumeFileName: state.resumeFileName,
- uploadPattern: state.uploadPattern,
- }))
- );
-
- // Pattern store
- const {
- pesData: pesDataProp,
- currentFileName,
- patternOffset,
- setPattern,
- } = usePatternStore(
- useShallow((state) => ({
- pesData: state.pesData,
- currentFileName: state.currentFileName,
- patternOffset: state.patternOffset,
- setPattern: state.setPattern,
- }))
- );
-
- // Derived state: pattern is uploaded if machine has pattern info
- const patternUploaded = usePatternUploaded();
-
- // UI store
- const {
- pyodideReady,
- pyodideProgress,
- pyodideLoadingStep,
- initializePyodide,
- } = useUIStore(
- useShallow((state) => ({
- pyodideReady: state.pyodideReady,
- pyodideProgress: state.pyodideProgress,
- pyodideLoadingStep: state.pyodideLoadingStep,
- initializePyodide: state.initializePyodide,
- }))
- );
- const [localPesData, setLocalPesData] = useState<PesPatternData | null>(null);
- const [fileName, setFileName] = useState<string>('');
- const [fileService] = useState<IFileService>(() => createFileService());
-
- // Use prop pesData if available (from cached pattern), otherwise use local state
- const pesData = pesDataProp || localPesData;
- // Use currentFileName from App state, or local fileName, or resumeFileName for display
- const displayFileName = currentFileName || fileName || resumeFileName || '';
- const [isLoading, setIsLoading] = useState(false);
-
- const handleFileChange = useCallback(
- async (event?: React.ChangeEvent<HTMLInputElement>) => {
- setIsLoading(true);
- try {
- // Wait for Pyodide if it's still loading
- if (!pyodideReady) {
- console.log('[FileUpload] Waiting for Pyodide to finish loading...');
- await initializePyodide();
- console.log('[FileUpload] Pyodide ready');
- }
-
- let file: File | null = null;
-
- // In Electron, use native file dialogs
- if (fileService.hasNativeDialogs()) {
- file = await fileService.openFileDialog({ accept: '.pes' });
- } else {
- // In browser, use the input element
- file = event?.target.files?.[0] || null;
- }
-
- if (!file) {
- setIsLoading(false);
- return;
- }
-
- const data = await convertPesToPen(file);
- setLocalPesData(data);
- setFileName(file.name);
- setPattern(data, file.name);
- } catch (err) {
- alert(
- `Failed to load PES file: ${
- err instanceof Error ? err.message : 'Unknown error'
- }`
- );
- } finally {
- setIsLoading(false);
- }
- },
- [fileService, setPattern, pyodideReady, initializePyodide]
- );
-
- const handleUpload = useCallback(() => {
- if (pesData && displayFileName) {
- uploadPattern(pesData.penData, pesData, displayFileName, patternOffset);
- }
- }, [pesData, displayFileName, uploadPattern, patternOffset]);
-
- // Check if pattern (with offset) fits within hoop bounds
- const checkPatternFitsInHoop = useCallback(() => {
- if (!pesData || !machineInfo) {
- return { fits: true, error: null };
- }
-
- const { bounds } = pesData;
- const { maxWidth, maxHeight } = machineInfo;
-
- // Calculate pattern bounds with offset applied
- const patternMinX = bounds.minX + patternOffset.x;
- const patternMaxX = bounds.maxX + patternOffset.x;
- const patternMinY = bounds.minY + patternOffset.y;
- const patternMaxY = bounds.maxY + patternOffset.y;
-
- // Hoop bounds (centered at origin)
- const hoopMinX = -maxWidth / 2;
- const hoopMaxX = maxWidth / 2;
- const hoopMinY = -maxHeight / 2;
- const hoopMaxY = maxHeight / 2;
-
- // Check if pattern exceeds hoop bounds
- const exceedsLeft = patternMinX < hoopMinX;
- const exceedsRight = patternMaxX > hoopMaxX;
- const exceedsTop = patternMinY < hoopMinY;
- const exceedsBottom = patternMaxY > hoopMaxY;
-
- if (exceedsLeft || exceedsRight || exceedsTop || exceedsBottom) {
- const directions = [];
- if (exceedsLeft) directions.push(`left by ${((hoopMinX - patternMinX) / 10).toFixed(1)}mm`);
- if (exceedsRight) directions.push(`right by ${((patternMaxX - hoopMaxX) / 10).toFixed(1)}mm`);
- if (exceedsTop) directions.push(`top by ${((hoopMinY - patternMinY) / 10).toFixed(1)}mm`);
- if (exceedsBottom) directions.push(`bottom by ${((patternMaxY - hoopMaxY) / 10).toFixed(1)}mm`);
-
- return {
- fits: false,
- error: `Pattern exceeds hoop bounds: ${directions.join(', ')}. Adjust pattern position in preview.`
- };
- }
-
- return { fits: true, error: null };
- }, [pesData, machineInfo, patternOffset]);
-
- const boundsCheck = checkPatternFitsInHoop();
-
- const borderColor = pesData ? 'border-secondary-600 dark:border-secondary-500' : 'border-gray-400 dark:border-gray-600';
- const iconColor = pesData ? 'text-secondary-600 dark:text-secondary-400' : 'text-gray-600 dark:text-gray-400';
-
- return (
- <div className={`bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor}`}>
- <div className="flex items-start gap-3 mb-3">
- <DocumentTextIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
- <div className="flex-1 min-w-0">
- <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Pattern File</h3>
- {pesData && displayFileName ? (
- <p className="text-xs text-gray-600 dark:text-gray-400 truncate" title={displayFileName}>
- {displayFileName}
- </p>
- ) : (
- <p className="text-xs text-gray-600 dark:text-gray-400">No pattern loaded</p>
- )}
- </div>
- </div>
-
- {resumeAvailable && resumeFileName && (
- <div className="bg-success-50 dark:bg-success-900/20 border border-success-200 dark:border-success-800 px-3 py-2 rounded mb-3">
- <p className="text-xs text-success-800 dark:text-success-200">
- <strong>Cached:</strong> "{resumeFileName}"
- </p>
- </div>
- )}
-
- {isLoading && <PatternInfoSkeleton />}
-
- {!isLoading && pesData && (
- <div className="mb-3">
- <PatternInfo pesData={pesData} showThreadBlocks />
- </div>
- )}
-
- <div className="flex gap-2 mb-3">
- <input
- type="file"
- accept=".pes"
- onChange={handleFileChange}
- id="file-input"
- className="hidden"
- disabled={isLoading || patternUploaded || isUploading}
- />
- <label
- htmlFor={fileService.hasNativeDialogs() ? undefined : "file-input"}
- onClick={fileService.hasNativeDialogs() ? () => handleFileChange() : undefined}
- className={`flex-[2] flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 rounded font-semibold text-sm transition-all ${
- isLoading || patternUploaded || isUploading
- ? 'opacity-50 cursor-not-allowed bg-gray-400 dark:bg-gray-600 text-white'
- : 'cursor-pointer bg-gray-600 dark:bg-gray-700 text-white hover:bg-gray-700 dark:hover:bg-gray-600'
- }`}
- >
- {isLoading ? (
- <>
- <svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
- </svg>
- <span>Loading...</span>
- </>
- ) : patternUploaded ? (
- <>
- <CheckCircleIcon className="w-3.5 h-3.5" />
- <span>Locked</span>
- </>
- ) : (
- <>
- <FolderOpenIcon className="w-3.5 h-3.5" />
- <span>Choose PES File</span>
- </>
- )}
- </label>
-
- {pesData && canUploadPattern(machineStatus) && !patternUploaded && uploadProgress < 100 && (
- <button
- onClick={handleUpload}
- disabled={!isConnected || isUploading || !boundsCheck.fits}
- className="flex-1 px-3 py-2.5 sm:py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
- aria-label={isUploading ? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete` : boundsCheck.error || 'Upload pattern to machine'}
- >
- {isUploading ? (
- <>
- <svg className="w-3.5 h-3.5 animate-spin inline mr-1" fill="none" viewBox="0 0 24 24">
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
- </svg>
- {uploadProgress > 0 ? uploadProgress.toFixed(0) + '%' : 'Uploading'}
- </>
- ) : (
- <>
- <ArrowUpTrayIcon className="w-3.5 h-3.5 inline mr-1" />
- Upload
- </>
- )}
- </button>
- )}
- </div>
-
- {/* Pyodide initialization progress indicator - shown when initializing or waiting */}
- {!pyodideReady && pyodideProgress > 0 && (
- <div className="mb-3">
- <div className="flex justify-between items-center mb-1.5">
- <span className="text-xs font-medium text-gray-600 dark:text-gray-400">
- {isLoading && !pyodideReady
- ? 'Please wait - initializing Python environment...'
- : pyodideLoadingStep || 'Initializing Python environment...'}
- </span>
- <span className="text-xs font-bold text-primary-600 dark:text-primary-400">
- {pyodideProgress.toFixed(0)}%
- </span>
- </div>
- <div className="h-2.5 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative">
- <div
- className="h-full bg-gradient-to-r from-primary-500 via-primary-600 to-primary-700 dark:from-primary-600 dark:via-primary-700 dark:to-primary-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite] rounded-full"
- style={{ width: `${pyodideProgress}%` }}
- />
- </div>
- <p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 italic">
- {isLoading && !pyodideReady
- ? 'File dialog will open automatically when ready'
- : 'This only happens once on first use'}
- </p>
- </div>
- )}
-
- {/* Error/warning messages with smooth transition - placed after buttons */}
- <div className="transition-all duration-200 ease-in-out overflow-hidden" style={{
- maxHeight: (pesData && (boundsCheck.error || !canUploadPattern(machineStatus))) ? '200px' : '0px',
- marginTop: (pesData && (boundsCheck.error || !canUploadPattern(machineStatus))) ? '12px' : '0px'
- }}>
- {pesData && !canUploadPattern(machineStatus) && (
- <div className="bg-warning-100 dark:bg-warning-900/20 text-warning-800 dark:text-warning-200 px-3 py-2 rounded border border-warning-200 dark:border-warning-800 text-sm">
- Cannot upload while {getMachineStateCategory(machineStatus)}
- </div>
- )}
-
- {pesData && boundsCheck.error && (
- <div className="bg-danger-100 dark:bg-danger-900/20 text-danger-800 dark:text-danger-200 px-3 py-2 rounded border border-danger-200 dark:border-danger-800 text-sm">
- <strong>Pattern too large:</strong> {boundsCheck.error}
- </div>
- )}
- </div>
-
- {isUploading && uploadProgress < 100 && (
- <div className="mt-3">
- <div className="flex justify-between items-center mb-1.5">
- <span className="text-xs font-medium text-gray-600 dark:text-gray-400">Uploading</span>
- <span className="text-xs font-bold text-secondary-600 dark:text-secondary-400">
- {uploadProgress > 0 ? uploadProgress.toFixed(1) + '%' : 'Starting...'}
- </span>
- </div>
- <div className="h-2.5 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative">
- <div
- className="h-full bg-gradient-to-r from-secondary-500 via-secondary-600 to-secondary-700 dark:from-secondary-600 dark:via-secondary-700 dark:to-secondary-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite] rounded-full"
- style={{ width: `${uploadProgress}%` }}
- />
- </div>
- </div>
- )}
- </div>
- );
-}
+import { useState, useCallback } from "react";
+import { useShallow } from "zustand/react/shallow";
+import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore";
+import { usePatternStore } from "../stores/usePatternStore";
+import { useUIStore } from "../stores/useUIStore";
+import {
+ convertPesToPen,
+ type PesPatternData,
+} from "../formats/import/pesImporter";
+import {
+ canUploadPattern,
+ getMachineStateCategory,
+} from "../utils/machineStateHelpers";
+import { PatternInfoSkeleton } from "./SkeletonLoader";
+import { PatternInfo } from "./PatternInfo";
+import {
+ ArrowUpTrayIcon,
+ CheckCircleIcon,
+ DocumentTextIcon,
+ FolderOpenIcon,
+} from "@heroicons/react/24/solid";
+import { createFileService } from "../platform";
+import type { IFileService } from "../platform/interfaces/IFileService";
+
+export function FileUpload() {
+ // Machine store
+ const {
+ isConnected,
+ machineStatus,
+ uploadProgress,
+ isUploading,
+ machineInfo,
+ resumeAvailable,
+ resumeFileName,
+ uploadPattern,
+ } = useMachineStore(
+ useShallow((state) => ({
+ isConnected: state.isConnected,
+ machineStatus: state.machineStatus,
+ uploadProgress: state.uploadProgress,
+ isUploading: state.isUploading,
+ machineInfo: state.machineInfo,
+ resumeAvailable: state.resumeAvailable,
+ resumeFileName: state.resumeFileName,
+ uploadPattern: state.uploadPattern,
+ })),
+ );
+
+ // Pattern store
+ const {
+ pesData: pesDataProp,
+ currentFileName,
+ patternOffset,
+ setPattern,
+ } = usePatternStore(
+ useShallow((state) => ({
+ pesData: state.pesData,
+ currentFileName: state.currentFileName,
+ patternOffset: state.patternOffset,
+ setPattern: state.setPattern,
+ })),
+ );
+
+ // Derived state: pattern is uploaded if machine has pattern info
+ const patternUploaded = usePatternUploaded();
+
+ // UI store
+ const {
+ pyodideReady,
+ pyodideProgress,
+ pyodideLoadingStep,
+ initializePyodide,
+ } = useUIStore(
+ useShallow((state) => ({
+ pyodideReady: state.pyodideReady,
+ pyodideProgress: state.pyodideProgress,
+ pyodideLoadingStep: state.pyodideLoadingStep,
+ initializePyodide: state.initializePyodide,
+ })),
+ );
+ const [localPesData, setLocalPesData] = useState<PesPatternData | null>(null);
+ const [fileName, setFileName] = useState<string>("");
+ const [fileService] = useState<IFileService>(() => createFileService());
+
+ // Use prop pesData if available (from cached pattern), otherwise use local state
+ const pesData = pesDataProp || localPesData;
+ // Use currentFileName from App state, or local fileName, or resumeFileName for display
+ const displayFileName = currentFileName || fileName || resumeFileName || "";
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleFileChange = useCallback(
+ async (event?: React.ChangeEvent<HTMLInputElement>) => {
+ setIsLoading(true);
+ try {
+ // Wait for Pyodide if it's still loading
+ if (!pyodideReady) {
+ console.log("[FileUpload] Waiting for Pyodide to finish loading...");
+ await initializePyodide();
+ console.log("[FileUpload] Pyodide ready");
+ }
+
+ let file: File | null = null;
+
+ // In Electron, use native file dialogs
+ if (fileService.hasNativeDialogs()) {
+ file = await fileService.openFileDialog({ accept: ".pes" });
+ } else {
+ // In browser, use the input element
+ file = event?.target.files?.[0] || null;
+ }
+
+ if (!file) {
+ setIsLoading(false);
+ return;
+ }
+
+ const data = await convertPesToPen(file);
+ setLocalPesData(data);
+ setFileName(file.name);
+ setPattern(data, file.name);
+ } catch (err) {
+ alert(
+ `Failed to load PES file: ${
+ err instanceof Error ? err.message : "Unknown error"
+ }`,
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [fileService, setPattern, pyodideReady, initializePyodide],
+ );
+
+ const handleUpload = useCallback(() => {
+ if (pesData && displayFileName) {
+ uploadPattern(pesData.penData, pesData, displayFileName, patternOffset);
+ }
+ }, [pesData, displayFileName, uploadPattern, patternOffset]);
+
+ // Check if pattern (with offset) fits within hoop bounds
+ const checkPatternFitsInHoop = useCallback(() => {
+ if (!pesData || !machineInfo) {
+ return { fits: true, error: null };
+ }
+
+ const { bounds } = pesData;
+ const { maxWidth, maxHeight } = machineInfo;
+
+ // Calculate pattern bounds with offset applied
+ const patternMinX = bounds.minX + patternOffset.x;
+ const patternMaxX = bounds.maxX + patternOffset.x;
+ const patternMinY = bounds.minY + patternOffset.y;
+ const patternMaxY = bounds.maxY + patternOffset.y;
+
+ // Hoop bounds (centered at origin)
+ const hoopMinX = -maxWidth / 2;
+ const hoopMaxX = maxWidth / 2;
+ const hoopMinY = -maxHeight / 2;
+ const hoopMaxY = maxHeight / 2;
+
+ // Check if pattern exceeds hoop bounds
+ const exceedsLeft = patternMinX < hoopMinX;
+ const exceedsRight = patternMaxX > hoopMaxX;
+ const exceedsTop = patternMinY < hoopMinY;
+ const exceedsBottom = patternMaxY > hoopMaxY;
+
+ if (exceedsLeft || exceedsRight || exceedsTop || exceedsBottom) {
+ const directions = [];
+ if (exceedsLeft)
+ directions.push(
+ `left by ${((hoopMinX - patternMinX) / 10).toFixed(1)}mm`,
+ );
+ if (exceedsRight)
+ directions.push(
+ `right by ${((patternMaxX - hoopMaxX) / 10).toFixed(1)}mm`,
+ );
+ if (exceedsTop)
+ directions.push(
+ `top by ${((hoopMinY - patternMinY) / 10).toFixed(1)}mm`,
+ );
+ if (exceedsBottom)
+ directions.push(
+ `bottom by ${((patternMaxY - hoopMaxY) / 10).toFixed(1)}mm`,
+ );
+
+ return {
+ fits: false,
+ error: `Pattern exceeds hoop bounds: ${directions.join(", ")}. Adjust pattern position in preview.`,
+ };
+ }
+
+ return { fits: true, error: null };
+ }, [pesData, machineInfo, patternOffset]);
+
+ const boundsCheck = checkPatternFitsInHoop();
+
+ const borderColor = pesData
+ ? "border-secondary-600 dark:border-secondary-500"
+ : "border-gray-400 dark:border-gray-600";
+ const iconColor = pesData
+ ? "text-secondary-600 dark:text-secondary-400"
+ : "text-gray-600 dark:text-gray-400";
+
+ return (
+ <div
+ className={`bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor}`}
+ >
+ <div className="flex items-start gap-3 mb-3">
+ <DocumentTextIcon
+ className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`}
+ />
+ <div className="flex-1 min-w-0">
+ <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
+ Pattern File
+ </h3>
+ {pesData && displayFileName ? (
+ <p
+ className="text-xs text-gray-600 dark:text-gray-400 truncate"
+ title={displayFileName}
+ >
+ {displayFileName}
+ </p>
+ ) : (
+ <p className="text-xs text-gray-600 dark:text-gray-400">
+ No pattern loaded
+ </p>
+ )}
+ </div>
+ </div>
+
+ {resumeAvailable && resumeFileName && (
+ <div className="bg-success-50 dark:bg-success-900/20 border border-success-200 dark:border-success-800 px-3 py-2 rounded mb-3">
+ <p className="text-xs text-success-800 dark:text-success-200">
+ <strong>Cached:</strong> "{resumeFileName}"
+ </p>
+ </div>
+ )}
+
+ {isLoading && <PatternInfoSkeleton />}
+
+ {!isLoading && pesData && (
+ <div className="mb-3">
+ <PatternInfo pesData={pesData} showThreadBlocks />
+ </div>
+ )}
+
+ <div className="flex gap-2 mb-3">
+ <input
+ type="file"
+ accept=".pes"
+ onChange={handleFileChange}
+ id="file-input"
+ className="hidden"
+ disabled={isLoading || patternUploaded || isUploading}
+ />
+ <label
+ htmlFor={fileService.hasNativeDialogs() ? undefined : "file-input"}
+ onClick={
+ fileService.hasNativeDialogs()
+ ? () => handleFileChange()
+ : undefined
+ }
+ className={`flex-[2] flex items-center justify-center gap-2 px-3 py-2.5 sm:py-2 rounded font-semibold text-sm transition-all ${
+ isLoading || patternUploaded || isUploading
+ ? "opacity-50 cursor-not-allowed bg-gray-400 dark:bg-gray-600 text-white"
+ : "cursor-pointer bg-gray-600 dark:bg-gray-700 text-white hover:bg-gray-700 dark:hover:bg-gray-600"
+ }`}
+ >
+ {isLoading ? (
+ <>
+ <svg
+ className="w-3.5 h-3.5 animate-spin"
+ fill="none"
+ viewBox="0 0 24 24"
+ >
+ <circle
+ className="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ strokeWidth="4"
+ ></circle>
+ <path
+ className="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+ ></path>
+ </svg>
+ <span>Loading...</span>
+ </>
+ ) : patternUploaded ? (
+ <>
+ <CheckCircleIcon className="w-3.5 h-3.5" />
+ <span>Locked</span>
+ </>
+ ) : (
+ <>
+ <FolderOpenIcon className="w-3.5 h-3.5" />
+ <span>Choose PES File</span>
+ </>
+ )}
+ </label>
+
+ {pesData &&
+ canUploadPattern(machineStatus) &&
+ !patternUploaded &&
+ uploadProgress < 100 && (
+ <button
+ onClick={handleUpload}
+ disabled={!isConnected || isUploading || !boundsCheck.fits}
+ className="flex-1 px-3 py-2.5 sm:py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-sm hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
+ aria-label={
+ isUploading
+ ? `Uploading pattern: ${uploadProgress.toFixed(0)}% complete`
+ : boundsCheck.error || "Upload pattern to machine"
+ }
+ >
+ {isUploading ? (
+ <>
+ <svg
+ className="w-3.5 h-3.5 animate-spin inline mr-1"
+ fill="none"
+ viewBox="0 0 24 24"
+ >
+ <circle
+ className="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ strokeWidth="4"
+ ></circle>
+ <path
+ className="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+ ></path>
+ </svg>
+ {uploadProgress > 0
+ ? uploadProgress.toFixed(0) + "%"
+ : "Uploading"}
+ </>
+ ) : (
+ <>
+ <ArrowUpTrayIcon className="w-3.5 h-3.5 inline mr-1" />
+ Upload
+ </>
+ )}
+ </button>
+ )}
+ </div>
+
+ {/* Pyodide initialization progress indicator - shown when initializing or waiting */}
+ {!pyodideReady && pyodideProgress > 0 && (
+ <div className="mb-3">
+ <div className="flex justify-between items-center mb-1.5">
+ <span className="text-xs font-medium text-gray-600 dark:text-gray-400">
+ {isLoading && !pyodideReady
+ ? "Please wait - initializing Python environment..."
+ : pyodideLoadingStep || "Initializing Python environment..."}
+ </span>
+ <span className="text-xs font-bold text-primary-600 dark:text-primary-400">
+ {pyodideProgress.toFixed(0)}%
+ </span>
+ </div>
+ <div className="h-2.5 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative">
+ <div
+ className="h-full bg-gradient-to-r from-primary-500 via-primary-600 to-primary-700 dark:from-primary-600 dark:via-primary-700 dark:to-primary-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite] rounded-full"
+ style={{ width: `${pyodideProgress}%` }}
+ />
+ </div>
+ <p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 italic">
+ {isLoading && !pyodideReady
+ ? "File dialog will open automatically when ready"
+ : "This only happens once on first use"}
+ </p>
+ </div>
+ )}
+
+ {/* Error/warning messages with smooth transition - placed after buttons */}
+ <div
+ className="transition-all duration-200 ease-in-out overflow-hidden"
+ style={{
+ maxHeight:
+ pesData && (boundsCheck.error || !canUploadPattern(machineStatus))
+ ? "200px"
+ : "0px",
+ marginTop:
+ pesData && (boundsCheck.error || !canUploadPattern(machineStatus))
+ ? "12px"
+ : "0px",
+ }}
+ >
+ {pesData && !canUploadPattern(machineStatus) && (
+ <div className="bg-warning-100 dark:bg-warning-900/20 text-warning-800 dark:text-warning-200 px-3 py-2 rounded border border-warning-200 dark:border-warning-800 text-sm">
+ Cannot upload while {getMachineStateCategory(machineStatus)}
+ </div>
+ )}
+
+ {pesData && boundsCheck.error && (
+ <div className="bg-danger-100 dark:bg-danger-900/20 text-danger-800 dark:text-danger-200 px-3 py-2 rounded border border-danger-200 dark:border-danger-800 text-sm">
+ <strong>Pattern too large:</strong> {boundsCheck.error}
+ </div>
+ )}
+ </div>
+
+ {isUploading && uploadProgress < 100 && (
+ <div className="mt-3">
+ <div className="flex justify-between items-center mb-1.5">
+ <span className="text-xs font-medium text-gray-600 dark:text-gray-400">
+ Uploading
+ </span>
+ <span className="text-xs font-bold text-secondary-600 dark:text-secondary-400">
+ {uploadProgress > 0
+ ? uploadProgress.toFixed(1) + "%"
+ : "Starting..."}
+ </span>
+ </div>
+ <div className="h-2.5 bg-gray-300 dark:bg-gray-600 rounded-full overflow-hidden shadow-inner relative">
+ <div
+ className="h-full bg-gradient-to-r from-secondary-500 via-secondary-600 to-secondary-700 dark:from-secondary-600 dark:via-secondary-700 dark:to-secondary-800 transition-all duration-300 ease-out relative overflow-hidden after:absolute after:inset-0 after:bg-gradient-to-r after:from-transparent after:via-white/30 after:to-transparent after:animate-[shimmer_2s_infinite] rounded-full"
+ style={{ width: `${uploadProgress}%` }}
+ />
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/src/components/KonvaComponents.tsx b/src/components/KonvaComponents.tsx
index 146471d..a8e15cb 100644
--- a/src/components/KonvaComponents.tsx
+++ b/src/components/KonvaComponents.tsx
@@ -1,10 +1,10 @@
-import { memo, useMemo } from 'react';
-import { Group, Line, Rect, Text, Circle } from 'react-konva';
-import type { PesPatternData } from '../formats/import/pesImporter';
-import { getThreadColor } from '../formats/import/pesImporter';
-import type { MachineInfo } from '../types/machine';
-import { MOVE } from '../formats/import/constants';
-import { canvasColors } from '../utils/cssVariables';
+import { memo, useMemo } from "react";
+import { Group, Line, Rect, Text, Circle } from "react-konva";
+import type { PesPatternData } from "../formats/import/pesImporter";
+import { getThreadColor } from "../formats/import/pesImporter";
+import type { MachineInfo } from "../types/machine";
+import { MOVE } from "../formats/import/constants";
+import { canvasColors } from "../utils/cssVariables";
interface GridProps {
gridSize: number;
@@ -23,12 +23,20 @@ export const Grid = memo(({ gridSize, bounds, machineInfo }: GridProps) => {
const horizontalLines: number[][] = [];
// Vertical lines
- for (let x = Math.floor(gridMinX / gridSize) * gridSize; x <= gridMaxX; x += gridSize) {
+ for (
+ let x = Math.floor(gridMinX / gridSize) * gridSize;
+ x <= gridMaxX;
+ x += gridSize
+ ) {
verticalLines.push([x, gridMinY, x, gridMaxY]);
}
// Horizontal lines
- for (let y = Math.floor(gridMinY / gridSize) * gridSize; y <= gridMaxY; y += gridSize) {
+ for (
+ let y = Math.floor(gridMinY / gridSize) * gridSize;
+ y <= gridMaxY;
+ y += gridSize
+ ) {
horizontalLines.push([gridMinX, y, gridMaxX, y]);
}
@@ -59,7 +67,7 @@ export const Grid = memo(({ gridSize, bounds, machineInfo }: GridProps) => {
);
});
-Grid.displayName = 'Grid';
+Grid.displayName = "Grid";
export const Origin = memo(() => {
const originColor = canvasColors.origin();
@@ -72,7 +80,7 @@ export const Origin = memo(() => {
);
});
-Origin.displayName = 'Origin';
+Origin.displayName = "Origin";
interface HoopProps {
machineInfo: MachineInfo;
@@ -108,7 +116,7 @@ export const Hoop = memo(({ machineInfo }: HoopProps) => {
);
});
-Hoop.displayName = 'Hoop';
+Hoop.displayName = "Hoop";
interface PatternBoundsProps {
bounds: { minX: number; maxX: number; minY: number; maxY: number };
@@ -133,7 +141,7 @@ export const PatternBounds = memo(({ bounds }: PatternBoundsProps) => {
);
});
-PatternBounds.displayName = 'PatternBounds';
+PatternBounds.displayName = "PatternBounds";
interface StitchesProps {
stitches: number[][];
@@ -142,113 +150,146 @@ interface StitchesProps {
showProgress?: boolean;
}
-export const Stitches = memo(({ stitches, pesData, currentStitchIndex, showProgress = false }: StitchesProps) => {
- const stitchGroups = useMemo(() => {
- interface StitchGroup {
- color: string;
- points: number[];
- completed: boolean;
- isJump: boolean;
- }
+export const Stitches = memo(
+ ({
+ stitches,
+ pesData,
+ currentStitchIndex,
+ showProgress = false,
+ }: StitchesProps) => {
+ const stitchGroups = useMemo(() => {
+ interface StitchGroup {
+ color: string;
+ points: number[];
+ completed: boolean;
+ isJump: boolean;
+ }
- const groups: StitchGroup[] = [];
- let currentGroup: StitchGroup | null = null;
-
- let prevX = 0;
- let prevY = 0;
-
- for (let i = 0; i < stitches.length; i++) {
- const stitch = stitches[i];
- const [x, y, cmd, colorIndex] = stitch;
- const isCompleted = i < currentStitchIndex;
- const isJump = (cmd & MOVE) !== 0;
- const color = getThreadColor(pesData, colorIndex);
-
- // Start new group if color/status/type changes
- if (
- !currentGroup ||
- currentGroup.color !== color ||
- currentGroup.completed !== isCompleted ||
- currentGroup.isJump !== isJump
- ) {
- // For jump stitches, we need to create a line from previous position to current position
- // So we include both the previous point and current point
- if (isJump && i > 0) {
- currentGroup = {
- color,
- points: [prevX, prevY, x, y],
- completed: isCompleted,
- isJump,
- };
+ const groups: StitchGroup[] = [];
+ let currentGroup: StitchGroup | null = null;
+
+ let prevX = 0;
+ let prevY = 0;
+
+ for (let i = 0; i < stitches.length; i++) {
+ const stitch = stitches[i];
+ const [x, y, cmd, colorIndex] = stitch;
+ const isCompleted = i < currentStitchIndex;
+ const isJump = (cmd & MOVE) !== 0;
+ const color = getThreadColor(pesData, colorIndex);
+
+ // Start new group if color/status/type changes
+ if (
+ !currentGroup ||
+ currentGroup.color !== color ||
+ currentGroup.completed !== isCompleted ||
+ currentGroup.isJump !== isJump
+ ) {
+ // For jump stitches, we need to create a line from previous position to current position
+ // So we include both the previous point and current point
+ if (isJump && i > 0) {
+ currentGroup = {
+ color,
+ points: [prevX, prevY, x, y],
+ completed: isCompleted,
+ isJump,
+ };
+ } else {
+ currentGroup = {
+ color,
+ points: [x, y],
+ completed: isCompleted,
+ isJump,
+ };
+ }
+ groups.push(currentGroup);
} else {
- currentGroup = {
- color,
- points: [x, y],
- completed: isCompleted,
- isJump,
- };
+ currentGroup.points.push(x, y);
}
- groups.push(currentGroup);
- } else {
- currentGroup.points.push(x, y);
- }
-
- prevX = x;
- prevY = y;
- }
-
- return groups;
- }, [stitches, pesData, currentStitchIndex]);
- return (
- <Group name="stitches">
- {stitchGroups.map((group, i) => (
- <Line
- key={i}
- points={group.points}
- stroke={group.color}
- strokeWidth={group.isJump ? 1.5 : 1.5}
- lineCap="round"
- lineJoin="round"
- dash={group.isJump ? [8, 4] : undefined}
- opacity={group.isJump ? (group.completed ? 0.8 : 0.5) : (showProgress && !group.completed ? 0.3 : 1.0)}
- />
- ))}
- </Group>
- );
-});
+ prevX = x;
+ prevY = y;
+ }
-Stitches.displayName = 'Stitches';
+ return groups;
+ }, [stitches, pesData, currentStitchIndex]);
+
+ return (
+ <Group name="stitches">
+ {stitchGroups.map((group, i) => (
+ <Line
+ key={i}
+ points={group.points}
+ stroke={group.color}
+ strokeWidth={group.isJump ? 1.5 : 1.5}
+ lineCap="round"
+ lineJoin="round"
+ dash={group.isJump ? [8, 4] : undefined}
+ opacity={
+ group.isJump
+ ? group.completed
+ ? 0.8
+ : 0.5
+ : showProgress && !group.completed
+ ? 0.3
+ : 1.0
+ }
+ />
+ ))}
+ </Group>
+ );
+ },
+);
+
+Stitches.displayName = "Stitches";
interface CurrentPositionProps {
currentStitchIndex: number;
stitches: number[][];
}
-export const CurrentPosition = memo(({ currentStitchIndex, stitches }: CurrentPositionProps) => {
- if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) {
- return null;
- }
-
- const [x, y] = stitches[currentStitchIndex];
- const positionColor = canvasColors.position();
+export const CurrentPosition = memo(
+ ({ currentStitchIndex, stitches }: CurrentPositionProps) => {
+ if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) {
+ return null;
+ }
- return (
- <Group name="currentPosition">
- <Circle
- x={x}
- y={y}
- radius={8}
- fill={`${positionColor}4d`}
- stroke={positionColor}
- strokeWidth={3}
- />
- <Line points={[x - 12, y, x - 3, y]} stroke={positionColor} strokeWidth={2} />
- <Line points={[x + 12, y, x + 3, y]} stroke={positionColor} strokeWidth={2} />
- <Line points={[x, y - 12, x, y - 3]} stroke={positionColor} strokeWidth={2} />
- <Line points={[x, y + 12, x, y + 3]} stroke={positionColor} strokeWidth={2} />
- </Group>
- );
-});
+ const [x, y] = stitches[currentStitchIndex];
+ const positionColor = canvasColors.position();
+
+ return (
+ <Group name="currentPosition">
+ <Circle
+ x={x}
+ y={y}
+ radius={8}
+ fill={`${positionColor}4d`}
+ stroke={positionColor}
+ strokeWidth={3}
+ />
+ <Line
+ points={[x - 12, y, x - 3, y]}
+ stroke={positionColor}
+ strokeWidth={2}
+ />
+ <Line
+ points={[x + 12, y, x + 3, y]}
+ stroke={positionColor}
+ strokeWidth={2}
+ />
+ <Line
+ points={[x, y - 12, x, y - 3]}
+ stroke={positionColor}
+ strokeWidth={2}
+ />
+ <Line
+ points={[x, y + 12, x, y + 3]}
+ stroke={positionColor}
+ strokeWidth={2}
+ />
+ </Group>
+ );
+ },
+);
-CurrentPosition.displayName = 'CurrentPosition';
+CurrentPosition.displayName = "CurrentPosition";
diff --git a/src/components/LeftSidebar.tsx b/src/components/LeftSidebar.tsx
index 29b4ae9..adddb0e 100644
--- a/src/components/LeftSidebar.tsx
+++ b/src/components/LeftSidebar.tsx
@@ -1,22 +1,22 @@
-import { useShallow } from 'zustand/react/shallow';
-import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore';
-import { usePatternStore } from '../stores/usePatternStore';
-import { ConnectionPrompt } from './ConnectionPrompt';
-import { FileUpload } from './FileUpload';
-import { PatternSummaryCard } from './PatternSummaryCard';
-import { ProgressMonitor } from './ProgressMonitor';
+import { useShallow } from "zustand/react/shallow";
+import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore";
+import { usePatternStore } from "../stores/usePatternStore";
+import { ConnectionPrompt } from "./ConnectionPrompt";
+import { FileUpload } from "./FileUpload";
+import { PatternSummaryCard } from "./PatternSummaryCard";
+import { ProgressMonitor } from "./ProgressMonitor";
export function LeftSidebar() {
const { isConnected } = useMachineStore(
useShallow((state) => ({
isConnected: state.isConnected,
- }))
+ })),
);
const { pesData } = usePatternStore(
useShallow((state) => ({
pesData: state.pesData,
- }))
+ })),
);
// Derived state: pattern is uploaded if machine has pattern info
diff --git a/src/components/MachineConnection.tsx b/src/components/MachineConnection.tsx
index dee49b5..ac85da1 100644
--- a/src/components/MachineConnection.tsx
+++ b/src/components/MachineConnection.tsx
@@ -1,189 +1,224 @@
-import { useState } from 'react';
-import {
- InformationCircleIcon,
- CheckCircleIcon,
- BoltIcon,
- PauseCircleIcon,
- ExclamationTriangleIcon,
- WifiIcon,
-} from '@heroicons/react/24/solid';
-import type { MachineInfo } from '../types/machine';
-import { MachineStatus } from '../types/machine';
-import { ConfirmDialog } from './ConfirmDialog';
-import { shouldConfirmDisconnect, getStateVisualInfo } from '../utils/machineStateHelpers';
-import { hasError, getErrorDetails } from '../utils/errorCodeHelpers';
-
-interface MachineConnectionProps {
- isConnected: boolean;
- machineInfo: MachineInfo | null;
- machineStatus: MachineStatus;
- machineStatusName: string;
- machineError: number;
- onConnect: () => void;
- onDisconnect: () => void;
- onRefresh: () => void;
-}
-
-export function MachineConnection({
- isConnected,
- machineInfo,
- machineStatus,
- machineStatusName,
- machineError,
- onConnect,
- onDisconnect,
-}: MachineConnectionProps) {
- const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
-
- const handleDisconnectClick = () => {
- if (shouldConfirmDisconnect(machineStatus)) {
- setShowDisconnectConfirm(true);
- } else {
- onDisconnect();
- }
- };
-
- const handleConfirmDisconnect = () => {
- setShowDisconnectConfirm(false);
- onDisconnect();
- };
-
- const stateVisual = getStateVisualInfo(machineStatus);
-
- // Map icon names to Heroicons
- const stateIcons = {
- ready: CheckCircleIcon,
- active: BoltIcon,
- waiting: PauseCircleIcon,
- complete: CheckCircleIcon,
- interrupted: PauseCircleIcon,
- error: ExclamationTriangleIcon,
- };
-
- const statusBadgeColors = {
- idle: 'bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300',
- info: 'bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300',
- active: 'bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300',
- waiting: 'bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300',
- warning: 'bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300',
- complete: 'bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300',
- success: 'bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300',
- interrupted: 'bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300',
- error: 'bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300',
- danger: 'bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300',
- };
-
- // Only show error info when connected AND there's an actual error
- const errorInfo = (isConnected && hasError(machineError)) ? getErrorDetails(machineError) : null;
-
- return (
- <>
- {!isConnected ? (
- <div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600">
- <div className="flex items-start gap-3 mb-3">
- <WifiIcon className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
- <div className="flex-1 min-w-0">
- <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Machine</h3>
- <p className="text-xs text-gray-600 dark:text-gray-400">Ready to connect</p>
- </div>
- </div>
-
- <button
- onClick={onConnect}
- className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-xs hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer"
- >
- Connect to Machine
- </button>
- </div>
- ) : (
- <div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-success-600 dark:border-success-500">
- <div className="flex items-start gap-3 mb-3">
- <WifiIcon className="w-6 h-6 text-success-600 dark:text-success-400 flex-shrink-0 mt-0.5" />
- <div className="flex-1 min-w-0">
- <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Machine Info</h3>
- <p className="text-xs text-gray-600 dark:text-gray-400">
- {machineInfo?.modelNumber || 'Brother Embroidery Machine'}
- </p>
- </div>
- </div>
-
- {/* Error/Info Display */}
- {errorInfo && (
- errorInfo.isInformational ? (
- <div className="mb-3 p-3 bg-info-50 dark:bg-info-900/20 border border-info-200 dark:border-info-800 rounded-lg">
- <div className="flex items-start gap-2">
- <InformationCircleIcon className="w-4 h-4 text-info-600 dark:text-info-400 flex-shrink-0" />
- <div className="flex-1 min-w-0">
- <div className="font-semibold text-info-900 dark:text-info-200 text-xs">{errorInfo.title}</div>
- </div>
- </div>
- </div>
- ) : (
- <div className="mb-3 p-3 bg-danger-50 dark:bg-danger-900/20 border border-danger-200 dark:border-danger-800 rounded-lg">
- <div className="flex items-start gap-2">
- <span className="text-danger-600 dark:text-danger-400 flex-shrink-0">⚠️</span>
- <div className="flex-1 min-w-0">
- <div className="font-semibold text-danger-900 dark:text-danger-200 text-xs mb-1">{errorInfo.title}</div>
- <div className="text-xs text-danger-700 dark:text-danger-300 font-mono">
- Error Code: 0x{machineError.toString(16).toUpperCase().padStart(2, '0')}
- </div>
- </div>
- </div>
- </div>
- )
- )}
-
- {/* Status Badge */}
- <div className="mb-3">
- <span className="text-xs text-gray-600 dark:text-gray-400 block mb-1">Status:</span>
- <span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg font-semibold text-xs ${statusBadgeColors[stateVisual.color as keyof typeof statusBadgeColors] || statusBadgeColors.info}`}>
- {(() => {
- const Icon = stateIcons[stateVisual.iconName];
- return <Icon className="w-3.5 h-3.5" />;
- })()}
- <span>{machineStatusName}</span>
- </span>
- </div>
-
- {/* Machine Info */}
- {machineInfo && (
- <div className="grid grid-cols-2 gap-2 text-xs mb-3">
- <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
- <span className="text-gray-600 dark:text-gray-400 block">Max Area</span>
- <span className="font-semibold text-gray-900 dark:text-gray-100">
- {(machineInfo.maxWidth / 10).toFixed(1)} × {(machineInfo.maxHeight / 10).toFixed(1)} mm
- </span>
- </div>
- {machineInfo.totalCount !== undefined && (
- <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
- <span className="text-gray-600 dark:text-gray-400 block">Total Stitches</span>
- <span className="font-semibold text-gray-900 dark:text-gray-100">
- {machineInfo.totalCount.toLocaleString()}
- </span>
- </div>
- )}
- </div>
- )}
-
- <button
- onClick={handleDisconnectClick}
- className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600 text-xs font-medium transition-colors cursor-pointer"
- >
- Disconnect
- </button>
- </div>
- )}
-
- <ConfirmDialog
- isOpen={showDisconnectConfirm}
- title="Confirm Disconnect"
- message={`The machine is currently ${machineStatusName.toLowerCase()}. Disconnecting may interrupt the operation. Are you sure you want to disconnect?`}
- confirmText="Disconnect Anyway"
- cancelText="Cancel"
- onConfirm={handleConfirmDisconnect}
- onCancel={() => setShowDisconnectConfirm(false)}
- variant="danger"
- />
- </>
- );
-}
+import { useState } from "react";
+import {
+ InformationCircleIcon,
+ CheckCircleIcon,
+ BoltIcon,
+ PauseCircleIcon,
+ ExclamationTriangleIcon,
+ WifiIcon,
+} from "@heroicons/react/24/solid";
+import type { MachineInfo } from "../types/machine";
+import { MachineStatus } from "../types/machine";
+import { ConfirmDialog } from "./ConfirmDialog";
+import {
+ shouldConfirmDisconnect,
+ getStateVisualInfo,
+} from "../utils/machineStateHelpers";
+import { hasError, getErrorDetails } from "../utils/errorCodeHelpers";
+
+interface MachineConnectionProps {
+ isConnected: boolean;
+ machineInfo: MachineInfo | null;
+ machineStatus: MachineStatus;
+ machineStatusName: string;
+ machineError: number;
+ onConnect: () => void;
+ onDisconnect: () => void;
+ onRefresh: () => void;
+}
+
+export function MachineConnection({
+ isConnected,
+ machineInfo,
+ machineStatus,
+ machineStatusName,
+ machineError,
+ onConnect,
+ onDisconnect,
+}: MachineConnectionProps) {
+ const [showDisconnectConfirm, setShowDisconnectConfirm] = useState(false);
+
+ const handleDisconnectClick = () => {
+ if (shouldConfirmDisconnect(machineStatus)) {
+ setShowDisconnectConfirm(true);
+ } else {
+ onDisconnect();
+ }
+ };
+
+ const handleConfirmDisconnect = () => {
+ setShowDisconnectConfirm(false);
+ onDisconnect();
+ };
+
+ const stateVisual = getStateVisualInfo(machineStatus);
+
+ // Map icon names to Heroicons
+ const stateIcons = {
+ ready: CheckCircleIcon,
+ active: BoltIcon,
+ waiting: PauseCircleIcon,
+ complete: CheckCircleIcon,
+ interrupted: PauseCircleIcon,
+ error: ExclamationTriangleIcon,
+ };
+
+ const statusBadgeColors = {
+ idle: "bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300",
+ info: "bg-info-100 dark:bg-info-900/30 text-info-800 dark:text-info-300",
+ active:
+ "bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300",
+ waiting:
+ "bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300",
+ warning:
+ "bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-300",
+ complete:
+ "bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300",
+ success:
+ "bg-success-100 dark:bg-success-900/30 text-success-800 dark:text-success-300",
+ interrupted:
+ "bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300",
+ error:
+ "bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300",
+ danger:
+ "bg-danger-100 dark:bg-danger-900/30 text-danger-800 dark:text-danger-300",
+ };
+
+ // Only show error info when connected AND there's an actual error
+ const errorInfo =
+ isConnected && hasError(machineError)
+ ? getErrorDetails(machineError)
+ : null;
+
+ return (
+ <>
+ {!isConnected ? (
+ <div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-gray-400 dark:border-gray-600">
+ <div className="flex items-start gap-3 mb-3">
+ <WifiIcon className="w-6 h-6 text-gray-600 dark:text-gray-400 flex-shrink-0 mt-0.5" />
+ <div className="flex-1 min-w-0">
+ <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
+ Machine
+ </h3>
+ <p className="text-xs text-gray-600 dark:text-gray-400">
+ Ready to connect
+ </p>
+ </div>
+ </div>
+
+ <button
+ onClick={onConnect}
+ className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-primary-600 dark:bg-primary-700 text-white rounded font-semibold text-xs hover:bg-primary-700 dark:hover:bg-primary-600 active:bg-primary-800 dark:active:bg-primary-500 transition-colors cursor-pointer"
+ >
+ Connect to Machine
+ </button>
+ </div>
+ ) : (
+ <div className="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 border-success-600 dark:border-success-500">
+ <div className="flex items-start gap-3 mb-3">
+ <WifiIcon className="w-6 h-6 text-success-600 dark:text-success-400 flex-shrink-0 mt-0.5" />
+ <div className="flex-1 min-w-0">
+ <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
+ Machine Info
+ </h3>
+ <p className="text-xs text-gray-600 dark:text-gray-400">
+ {machineInfo?.modelNumber || "Brother Embroidery Machine"}
+ </p>
+ </div>
+ </div>
+
+ {/* Error/Info Display */}
+ {errorInfo &&
+ (errorInfo.isInformational ? (
+ <div className="mb-3 p-3 bg-info-50 dark:bg-info-900/20 border border-info-200 dark:border-info-800 rounded-lg">
+ <div className="flex items-start gap-2">
+ <InformationCircleIcon className="w-4 h-4 text-info-600 dark:text-info-400 flex-shrink-0" />
+ <div className="flex-1 min-w-0">
+ <div className="font-semibold text-info-900 dark:text-info-200 text-xs">
+ {errorInfo.title}
+ </div>
+ </div>
+ </div>
+ </div>
+ ) : (
+ <div className="mb-3 p-3 bg-danger-50 dark:bg-danger-900/20 border border-danger-200 dark:border-danger-800 rounded-lg">
+ <div className="flex items-start gap-2">
+ <span className="text-danger-600 dark:text-danger-400 flex-shrink-0">
+ ⚠️
+ </span>
+ <div className="flex-1 min-w-0">
+ <div className="font-semibold text-danger-900 dark:text-danger-200 text-xs mb-1">
+ {errorInfo.title}
+ </div>
+ <div className="text-xs text-danger-700 dark:text-danger-300 font-mono">
+ Error Code: 0x
+ {machineError.toString(16).toUpperCase().padStart(2, "0")}
+ </div>
+ </div>
+ </div>
+ </div>
+ ))}
+
+ {/* Status Badge */}
+ <div className="mb-3">
+ <span className="text-xs text-gray-600 dark:text-gray-400 block mb-1">
+ Status:
+ </span>
+ <span
+ className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg font-semibold text-xs ${statusBadgeColors[stateVisual.color as keyof typeof statusBadgeColors] || statusBadgeColors.info}`}
+ >
+ {(() => {
+ const Icon = stateIcons[stateVisual.iconName];
+ return <Icon className="w-3.5 h-3.5" />;
+ })()}
+ <span>{machineStatusName}</span>
+ </span>
+ </div>
+
+ {/* Machine Info */}
+ {machineInfo && (
+ <div className="grid grid-cols-2 gap-2 text-xs mb-3">
+ <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
+ <span className="text-gray-600 dark:text-gray-400 block">
+ Max Area
+ </span>
+ <span className="font-semibold text-gray-900 dark:text-gray-100">
+ {(machineInfo.maxWidth / 10).toFixed(1)} ×{" "}
+ {(machineInfo.maxHeight / 10).toFixed(1)} mm
+ </span>
+ </div>
+ {machineInfo.totalCount !== undefined && (
+ <div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
+ <span className="text-gray-600 dark:text-gray-400 block">
+ Total Stitches
+ </span>
+ <span className="font-semibold text-gray-900 dark:text-gray-100">
+ {machineInfo.totalCount.toLocaleString()}
+ </span>
+ </div>
+ )}
+ </div>
+ )}
+
+ <button
+ onClick={handleDisconnectClick}
+ className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded border border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600 text-xs font-medium transition-colors cursor-pointer"
+ >
+ Disconnect
+ </button>
+ </div>
+ )}
+
+ <ConfirmDialog
+ isOpen={showDisconnectConfirm}
+ title="Confirm Disconnect"
+ message={`The machine is currently ${machineStatusName.toLowerCase()}. Disconnecting may interrupt the operation. Are you sure you want to disconnect?`}
+ confirmText="Disconnect Anyway"
+ cancelText="Cancel"
+ onConfirm={handleConfirmDisconnect}
+ onCancel={() => setShowDisconnectConfirm(false)}
+ variant="danger"
+ />
+ </>
+ );
+}
diff --git a/src/components/PatternCanvas.tsx b/src/components/PatternCanvas.tsx
index 2b14a8d..b9099b8 100644
--- a/src/components/PatternCanvas.tsx
+++ b/src/components/PatternCanvas.tsx
@@ -1,424 +1,516 @@
-import { useEffect, useRef, useState, useCallback } from 'react';
-import { useShallow } from 'zustand/react/shallow';
-import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore';
-import { usePatternStore } from '../stores/usePatternStore';
-import { Stage, Layer, Group } from 'react-konva';
-import Konva from 'konva';
-import { PlusIcon, MinusIcon, ArrowPathIcon, LockClosedIcon, PhotoIcon, ArrowsPointingInIcon } from '@heroicons/react/24/solid';
-import type { PesPatternData } from '../formats/import/pesImporter';
-import { calculateInitialScale } from '../utils/konvaRenderers';
-import { Grid, Origin, Hoop, Stitches, PatternBounds, CurrentPosition } from './KonvaComponents';
-
-export function PatternCanvas() {
- // Machine store
- const {
- sewingProgress,
- machineInfo,
- isUploading,
- } = useMachineStore(
- useShallow((state) => ({
- sewingProgress: state.sewingProgress,
- machineInfo: state.machineInfo,
- isUploading: state.isUploading,
- }))
- );
-
- // Pattern store
- const {
- pesData,
- patternOffset: initialPatternOffset,
- setPatternOffset,
- } = usePatternStore(
- useShallow((state) => ({
- pesData: state.pesData,
- patternOffset: state.patternOffset,
- setPatternOffset: state.setPatternOffset,
- }))
- );
-
- // Derived state: pattern is uploaded if machine has pattern info
- const patternUploaded = usePatternUploaded();
- const containerRef = useRef<HTMLDivElement>(null);
- const stageRef = useRef<Konva.Stage | null>(null);
-
- const [stagePos, setStagePos] = useState({ x: 0, y: 0 });
- const [stageScale, setStageScale] = useState(1);
- const [localPatternOffset, setLocalPatternOffset] = useState(initialPatternOffset || { x: 0, y: 0 });
- const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
- const initialScaleRef = useRef<number>(1);
- const prevPesDataRef = useRef<PesPatternData | null>(null);
-
- // Update pattern offset when initialPatternOffset changes
- if (initialPatternOffset && (
- localPatternOffset.x !== initialPatternOffset.x ||
- localPatternOffset.y !== initialPatternOffset.y
- )) {
- setLocalPatternOffset(initialPatternOffset);
- console.log('[PatternCanvas] Restored pattern offset:', initialPatternOffset);
- }
-
- // Track container size
- useEffect(() => {
- if (!containerRef.current) return;
-
- const updateSize = () => {
- if (containerRef.current) {
- const width = containerRef.current.clientWidth;
- const height = containerRef.current.clientHeight;
- setContainerSize({ width, height });
- }
- };
-
- // Initial size
- updateSize();
-
- // Watch for resize
- const resizeObserver = new ResizeObserver(updateSize);
- resizeObserver.observe(containerRef.current);
-
- return () => resizeObserver.disconnect();
- }, []);
-
- // Calculate and store initial scale when pattern or hoop changes
- useEffect(() => {
- if (!pesData || containerSize.width === 0) {
- prevPesDataRef.current = null;
- return;
- }
-
- // Only recalculate if pattern changed
- if (prevPesDataRef.current !== pesData) {
- prevPesDataRef.current = pesData;
-
- const { bounds } = pesData;
- const viewWidth = machineInfo ? machineInfo.maxWidth : bounds.maxX - bounds.minX;
- const viewHeight = machineInfo ? machineInfo.maxHeight : bounds.maxY - bounds.minY;
-
- const initialScale = calculateInitialScale(containerSize.width, containerSize.height, viewWidth, viewHeight);
- initialScaleRef.current = initialScale;
-
- // Reset view when pattern changes
- // eslint-disable-next-line react-hooks/set-state-in-effect
- setStageScale(initialScale);
- setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
- }
- }, [pesData, machineInfo, containerSize]);
-
- // Wheel zoom handler
- const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
- e.evt.preventDefault();
-
- const stage = e.target.getStage();
- if (!stage) return;
-
- const pointer = stage.getPointerPosition();
- if (!pointer) return;
-
- const scaleBy = 1.1;
- const direction = e.evt.deltaY > 0 ? -1 : 1;
-
- setStageScale((oldScale) => {
- const newScale = Math.max(0.1, Math.min(direction > 0 ? oldScale * scaleBy : oldScale / scaleBy, 2));
-
- // Zoom towards pointer
- setStagePos((prevPos) => {
- const mousePointTo = {
- x: (pointer.x - prevPos.x) / oldScale,
- y: (pointer.y - prevPos.y) / oldScale,
- };
-
- return {
- x: pointer.x - mousePointTo.x * newScale,
- y: pointer.y - mousePointTo.y * newScale,
- };
- });
-
- return newScale;
- });
- }, []);
-
- // Zoom control handlers
- const handleZoomIn = useCallback(() => {
- setStageScale((oldScale) => {
- const newScale = Math.max(0.1, Math.min(oldScale * 1.2, 2));
-
- // Zoom towards center of viewport
- setStagePos((prevPos) => {
- const centerX = containerSize.width / 2;
- const centerY = containerSize.height / 2;
-
- const mousePointTo = {
- x: (centerX - prevPos.x) / oldScale,
- y: (centerY - prevPos.y) / oldScale,
- };
-
- return {
- x: centerX - mousePointTo.x * newScale,
- y: centerY - mousePointTo.y * newScale,
- };
- });
-
- return newScale;
- });
- }, [containerSize]);
-
- const handleZoomOut = useCallback(() => {
- setStageScale((oldScale) => {
- const newScale = Math.max(0.1, Math.min(oldScale / 1.2, 2));
-
- // Zoom towards center of viewport
- setStagePos((prevPos) => {
- const centerX = containerSize.width / 2;
- const centerY = containerSize.height / 2;
-
- const mousePointTo = {
- x: (centerX - prevPos.x) / oldScale,
- y: (centerY - prevPos.y) / oldScale,
- };
-
- return {
- x: centerX - mousePointTo.x * newScale,
- y: centerY - mousePointTo.y * newScale,
- };
- });
-
- return newScale;
- });
- }, [containerSize]);
-
- const handleZoomReset = useCallback(() => {
- const initialScale = initialScaleRef.current;
- setStageScale(initialScale);
- setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
- }, [containerSize]);
-
- const handleCenterPattern = useCallback(() => {
- if (!pesData) return;
-
- const { bounds } = pesData;
- const centerOffsetX = -(bounds.minX + bounds.maxX) / 2;
- const centerOffsetY = -(bounds.minY + bounds.maxY) / 2;
-
- setLocalPatternOffset({ x: centerOffsetX, y: centerOffsetY });
- setPatternOffset(centerOffsetX, centerOffsetY);
- }, [pesData, setPatternOffset]);
-
- // Pattern drag handlers
- const handlePatternDragEnd = useCallback((e: Konva.KonvaEventObject<DragEvent>) => {
- const newOffset = {
- x: e.target.x(),
- y: e.target.y(),
- };
- setLocalPatternOffset(newOffset);
- setPatternOffset(newOffset.x, newOffset.y);
- }, [setPatternOffset]);
-
- const borderColor = pesData ? 'border-tertiary-600 dark:border-tertiary-500' : 'border-gray-400 dark:border-gray-600';
- const iconColor = pesData ? 'text-tertiary-600 dark:text-tertiary-400' : 'text-gray-600 dark:text-gray-400';
-
- return (
- <div className={`lg:h-full bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor} flex flex-col`}>
- <div className="flex items-start gap-3 mb-3 flex-shrink-0">
- <PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
- <div className="flex-1 min-w-0">
- <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Pattern Preview</h3>
- {pesData ? (
- <p className="text-xs text-gray-600 dark:text-gray-400">
- {((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} × {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
- </p>
- ) : (
- <p className="text-xs text-gray-600 dark:text-gray-400">No pattern loaded</p>
- )}
- </div>
- </div>
- <div className="relative w-full h-[400px] sm:h-[500px] lg:flex-1 lg:min-h-0 border border-gray-300 dark:border-gray-600 rounded bg-gray-200 dark:bg-gray-900 overflow-hidden" ref={containerRef}>
- {containerSize.width > 0 && (
- <Stage
- width={containerSize.width}
- height={containerSize.height}
- x={stagePos.x}
- y={stagePos.y}
- scaleX={stageScale}
- scaleY={stageScale}
- draggable
- onWheel={handleWheel}
- onDragStart={() => {
- if (stageRef.current) {
- stageRef.current.container().style.cursor = 'grabbing';
- }
- }}
- onDragEnd={() => {
- if (stageRef.current) {
- stageRef.current.container().style.cursor = 'grab';
- }
- }}
- ref={(node) => {
- stageRef.current = node;
- if (node) {
- node.container().style.cursor = 'grab';
- }
- }}
- >
- {/* Background layer: grid, origin, hoop */}
- <Layer>
- {pesData && (
- <>
- <Grid
- gridSize={100}
- bounds={pesData.bounds}
- machineInfo={machineInfo}
- />
- <Origin />
- {machineInfo && <Hoop machineInfo={machineInfo} />}
- </>
- )}
- </Layer>
-
- {/* Pattern layer: draggable stitches and bounds */}
- <Layer>
- {pesData && (
- <Group
- name="pattern-group"
- draggable={!patternUploaded && !isUploading}
- x={localPatternOffset.x}
- y={localPatternOffset.y}
- onDragEnd={handlePatternDragEnd}
- onMouseEnter={(e) => {
- const stage = e.target.getStage();
- if (stage && !patternUploaded && !isUploading) stage.container().style.cursor = 'move';
- }}
- onMouseLeave={(e) => {
- const stage = e.target.getStage();
- if (stage && !patternUploaded && !isUploading) stage.container().style.cursor = 'grab';
- }}
- >
- <Stitches
- stitches={pesData.penStitches.stitches.map((s, i): [number, number, number, number] => {
- // Convert PEN stitch format {x, y, flags, isJump} to PES format [x, y, cmd, colorIndex]
- const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump
- const colorIndex = pesData.penStitches.colorBlocks.find(
- (b) => i >= b.startStitch && i <= b.endStitch
- )?.colorIndex ?? 0;
- return [s.x, s.y, cmd, colorIndex];
- })}
- pesData={pesData}
- currentStitchIndex={sewingProgress?.currentStitch || 0}
- showProgress={patternUploaded || isUploading}
- />
- <PatternBounds bounds={pesData.bounds} />
- </Group>
- )}
- </Layer>
-
- {/* Current position layer */}
- <Layer>
- {pesData && pesData.penStitches && sewingProgress && sewingProgress.currentStitch > 0 && (
- <Group x={localPatternOffset.x} y={localPatternOffset.y}>
- <CurrentPosition
- currentStitchIndex={sewingProgress.currentStitch}
- stitches={pesData.penStitches.stitches.map((s, i): [number, number, number, number] => {
- const cmd = s.isJump ? 0x10 : 0;
- const colorIndex = pesData.penStitches.colorBlocks.find(
- (b) => i >= b.startStitch && i <= b.endStitch
- )?.colorIndex ?? 0;
- return [s.x, s.y, cmd, colorIndex];
- })}
- />
- </Group>
- )}
- </Layer>
- </Stage>
- )}
-
- {/* Placeholder overlay when no pattern is loaded */}
- {!pesData && (
- <div className="flex items-center justify-center h-full text-gray-600 dark:text-gray-400 italic">
- Load a PES file to preview the pattern
- </div>
- )}
-
- {/* Pattern info overlays */}
- {pesData && (
- <>
- {/* Thread Legend Overlay */}
- <div className="absolute top-2 sm:top-2.5 left-2 sm:left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2 sm:p-2.5 rounded-lg shadow-lg z-10 max-w-[150px] sm:max-w-[180px] lg:max-w-[200px]">
- <h4 className="m-0 mb-1.5 sm:mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1 sm:pb-1.5">Colors</h4>
- {pesData.uniqueColors.map((color, idx) => {
- // Primary metadata: brand and catalog number
- const primaryMetadata = [
- color.brand,
- color.catalogNumber ? `#${color.catalogNumber}` : null
- ].filter(Boolean).join(" ");
-
- // Secondary metadata: chart and description
- const secondaryMetadata = [
- color.chart,
- color.description
- ].filter(Boolean).join(" ");
-
- return (
- <div key={idx} className="flex items-start gap-1.5 sm:gap-2 mb-1 sm:mb-1.5 last:mb-0">
- <div
- className="w-3 h-3 sm:w-4 sm:h-4 rounded border border-black dark:border-gray-300 flex-shrink-0 mt-0.5"
- style={{ backgroundColor: color.hex }}
- />
- <div className="flex-1 min-w-0">
- <div className="text-xs font-semibold text-gray-900 dark:text-gray-100">
- Color {idx + 1}
- </div>
- {(primaryMetadata || secondaryMetadata) && (
- <div className="text-xs text-gray-600 dark:text-gray-400 leading-tight mt-0.5 break-words">
- {primaryMetadata}
- {primaryMetadata && secondaryMetadata && <span className="mx-1">•</span>}
- {secondaryMetadata}
- </div>
- )}
- </div>
- </div>
- );
- })}
- </div>
-
- {/* Pattern Offset Indicator */}
- <div className={`absolute bottom-16 sm:bottom-20 right-2 sm:right-5 backdrop-blur-sm p-2 sm:p-2.5 px-2.5 sm:px-3.5 rounded-lg shadow-lg z-[11] min-w-[160px] sm:min-w-[180px] transition-colors ${
- patternUploaded ? 'bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600' : 'bg-white/95 dark:bg-gray-800/95'
- }`}>
- <div className="flex items-center justify-between mb-1">
- <div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">Pattern Position:</div>
- {patternUploaded && (
- <div className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
- <LockClosedIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
- <span className="text-xs font-bold">LOCKED</span>
- </div>
- )}
- </div>
- <div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
- X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y: {(localPatternOffset.y / 10).toFixed(1)}mm
- </div>
- <div className="text-xs text-gray-600 dark:text-gray-400 italic">
- {patternUploaded ? 'Pattern locked • Drag background to pan' : 'Drag pattern to move • Drag background to pan'}
- </div>
- </div>
-
- {/* Zoom Controls Overlay */}
- <div className="absolute bottom-2 sm:bottom-5 right-2 sm:right-5 flex gap-1.5 sm:gap-2 items-center bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg shadow-lg z-10">
- <button className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed" onClick={handleCenterPattern} disabled={!pesData || patternUploaded || isUploading} title="Center Pattern in Hoop">
- <ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
- </button>
- <button className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed" onClick={handleZoomIn} title="Zoom In">
- <PlusIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
- </button>
- <span className="min-w-[40px] sm:min-w-[50px] text-center text-sm font-semibold text-gray-900 dark:text-gray-100 select-none">{Math.round(stageScale * 100)}%</span>
- <button className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed" onClick={handleZoomOut} title="Zoom Out">
- <MinusIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
- </button>
- <button className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed ml-1" onClick={handleZoomReset} title="Reset Zoom">
- <ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
- </button>
- </div>
- </>
- )}
- </div>
- </div>
- );
-}
+import { useEffect, useRef, useState, useCallback } from "react";
+import { useShallow } from "zustand/react/shallow";
+import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore";
+import { usePatternStore } from "../stores/usePatternStore";
+import { Stage, Layer, Group } from "react-konva";
+import Konva from "konva";
+import {
+ PlusIcon,
+ MinusIcon,
+ ArrowPathIcon,
+ LockClosedIcon,
+ PhotoIcon,
+ ArrowsPointingInIcon,
+} from "@heroicons/react/24/solid";
+import type { PesPatternData } from "../formats/import/pesImporter";
+import { calculateInitialScale } from "../utils/konvaRenderers";
+import {
+ Grid,
+ Origin,
+ Hoop,
+ Stitches,
+ PatternBounds,
+ CurrentPosition,
+} from "./KonvaComponents";
+
+export function PatternCanvas() {
+ // Machine store
+ const { sewingProgress, machineInfo, isUploading } = useMachineStore(
+ useShallow((state) => ({
+ sewingProgress: state.sewingProgress,
+ machineInfo: state.machineInfo,
+ isUploading: state.isUploading,
+ })),
+ );
+
+ // Pattern store
+ const {
+ pesData,
+ patternOffset: initialPatternOffset,
+ setPatternOffset,
+ } = usePatternStore(
+ useShallow((state) => ({
+ pesData: state.pesData,
+ patternOffset: state.patternOffset,
+ setPatternOffset: state.setPatternOffset,
+ })),
+ );
+
+ // Derived state: pattern is uploaded if machine has pattern info
+ const patternUploaded = usePatternUploaded();
+ const containerRef = useRef<HTMLDivElement>(null);
+ const stageRef = useRef<Konva.Stage | null>(null);
+
+ const [stagePos, setStagePos] = useState({ x: 0, y: 0 });
+ const [stageScale, setStageScale] = useState(1);
+ const [localPatternOffset, setLocalPatternOffset] = useState(
+ initialPatternOffset || { x: 0, y: 0 },
+ );
+ const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
+ const initialScaleRef = useRef<number>(1);
+ const prevPesDataRef = useRef<PesPatternData | null>(null);
+
+ // Update pattern offset when initialPatternOffset changes
+ if (
+ initialPatternOffset &&
+ (localPatternOffset.x !== initialPatternOffset.x ||
+ localPatternOffset.y !== initialPatternOffset.y)
+ ) {
+ setLocalPatternOffset(initialPatternOffset);
+ console.log(
+ "[PatternCanvas] Restored pattern offset:",
+ initialPatternOffset,
+ );
+ }
+
+ // Track container size
+ useEffect(() => {
+ if (!containerRef.current) return;
+
+ const updateSize = () => {
+ if (containerRef.current) {
+ const width = containerRef.current.clientWidth;
+ const height = containerRef.current.clientHeight;
+ setContainerSize({ width, height });
+ }
+ };
+
+ // Initial size
+ updateSize();
+
+ // Watch for resize
+ const resizeObserver = new ResizeObserver(updateSize);
+ resizeObserver.observe(containerRef.current);
+
+ return () => resizeObserver.disconnect();
+ }, []);
+
+ // Calculate and store initial scale when pattern or hoop changes
+ useEffect(() => {
+ if (!pesData || containerSize.width === 0) {
+ prevPesDataRef.current = null;
+ return;
+ }
+
+ // Only recalculate if pattern changed
+ if (prevPesDataRef.current !== pesData) {
+ prevPesDataRef.current = pesData;
+
+ const { bounds } = pesData;
+ const viewWidth = machineInfo
+ ? machineInfo.maxWidth
+ : bounds.maxX - bounds.minX;
+ const viewHeight = machineInfo
+ ? machineInfo.maxHeight
+ : bounds.maxY - bounds.minY;
+
+ const initialScale = calculateInitialScale(
+ containerSize.width,
+ containerSize.height,
+ viewWidth,
+ viewHeight,
+ );
+ initialScaleRef.current = initialScale;
+
+ // Reset view when pattern changes
+ // eslint-disable-next-line react-hooks/set-state-in-effect
+ setStageScale(initialScale);
+ setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
+ }
+ }, [pesData, machineInfo, containerSize]);
+
+ // Wheel zoom handler
+ const handleWheel = useCallback((e: Konva.KonvaEventObject<WheelEvent>) => {
+ e.evt.preventDefault();
+
+ const stage = e.target.getStage();
+ if (!stage) return;
+
+ const pointer = stage.getPointerPosition();
+ if (!pointer) return;
+
+ const scaleBy = 1.1;
+ const direction = e.evt.deltaY > 0 ? -1 : 1;
+
+ setStageScale((oldScale) => {
+ const newScale = Math.max(
+ 0.1,
+ Math.min(direction > 0 ? oldScale * scaleBy : oldScale / scaleBy, 2),
+ );
+
+ // Zoom towards pointer
+ setStagePos((prevPos) => {
+ const mousePointTo = {
+ x: (pointer.x - prevPos.x) / oldScale,
+ y: (pointer.y - prevPos.y) / oldScale,
+ };
+
+ return {
+ x: pointer.x - mousePointTo.x * newScale,
+ y: pointer.y - mousePointTo.y * newScale,
+ };
+ });
+
+ return newScale;
+ });
+ }, []);
+
+ // Zoom control handlers
+ const handleZoomIn = useCallback(() => {
+ setStageScale((oldScale) => {
+ const newScale = Math.max(0.1, Math.min(oldScale * 1.2, 2));
+
+ // Zoom towards center of viewport
+ setStagePos((prevPos) => {
+ const centerX = containerSize.width / 2;
+ const centerY = containerSize.height / 2;
+
+ const mousePointTo = {
+ x: (centerX - prevPos.x) / oldScale,
+ y: (centerY - prevPos.y) / oldScale,
+ };
+
+ return {
+ x: centerX - mousePointTo.x * newScale,
+ y: centerY - mousePointTo.y * newScale,
+ };
+ });
+
+ return newScale;
+ });
+ }, [containerSize]);
+
+ const handleZoomOut = useCallback(() => {
+ setStageScale((oldScale) => {
+ const newScale = Math.max(0.1, Math.min(oldScale / 1.2, 2));
+
+ // Zoom towards center of viewport
+ setStagePos((prevPos) => {
+ const centerX = containerSize.width / 2;
+ const centerY = containerSize.height / 2;
+
+ const mousePointTo = {
+ x: (centerX - prevPos.x) / oldScale,
+ y: (centerY - prevPos.y) / oldScale,
+ };
+
+ return {
+ x: centerX - mousePointTo.x * newScale,
+ y: centerY - mousePointTo.y * newScale,
+ };
+ });
+
+ return newScale;
+ });
+ }, [containerSize]);
+
+ const handleZoomReset = useCallback(() => {
+ const initialScale = initialScaleRef.current;
+ setStageScale(initialScale);
+ setStagePos({ x: containerSize.width / 2, y: containerSize.height / 2 });
+ }, [containerSize]);
+
+ const handleCenterPattern = useCallback(() => {
+ if (!pesData) return;
+
+ const { bounds } = pesData;
+ const centerOffsetX = -(bounds.minX + bounds.maxX) / 2;
+ const centerOffsetY = -(bounds.minY + bounds.maxY) / 2;
+
+ setLocalPatternOffset({ x: centerOffsetX, y: centerOffsetY });
+ setPatternOffset(centerOffsetX, centerOffsetY);
+ }, [pesData, setPatternOffset]);
+
+ // Pattern drag handlers
+ const handlePatternDragEnd = useCallback(
+ (e: Konva.KonvaEventObject<DragEvent>) => {
+ const newOffset = {
+ x: e.target.x(),
+ y: e.target.y(),
+ };
+ setLocalPatternOffset(newOffset);
+ setPatternOffset(newOffset.x, newOffset.y);
+ },
+ [setPatternOffset],
+ );
+
+ const borderColor = pesData
+ ? "border-tertiary-600 dark:border-tertiary-500"
+ : "border-gray-400 dark:border-gray-600";
+ const iconColor = pesData
+ ? "text-tertiary-600 dark:text-tertiary-400"
+ : "text-gray-600 dark:text-gray-400";
+
+ return (
+ <div
+ className={`lg:h-full bg-white dark:bg-gray-800 p-4 rounded-lg shadow-md border-l-4 ${borderColor} flex flex-col`}
+ >
+ <div className="flex items-start gap-3 mb-3 flex-shrink-0">
+ <PhotoIcon className={`w-6 h-6 ${iconColor} flex-shrink-0 mt-0.5`} />
+ <div className="flex-1 min-w-0">
+ <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
+ Pattern Preview
+ </h3>
+ {pesData ? (
+ <p className="text-xs text-gray-600 dark:text-gray-400">
+ {((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} ×{" "}
+ {((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
+ </p>
+ ) : (
+ <p className="text-xs text-gray-600 dark:text-gray-400">
+ No pattern loaded
+ </p>
+ )}
+ </div>
+ </div>
+ <div
+ className="relative w-full h-[400px] sm:h-[500px] lg:flex-1 lg:min-h-0 border border-gray-300 dark:border-gray-600 rounded bg-gray-200 dark:bg-gray-900 overflow-hidden"
+ ref={containerRef}
+ >
+ {containerSize.width > 0 && (
+ <Stage
+ width={containerSize.width}
+ height={containerSize.height}
+ x={stagePos.x}
+ y={stagePos.y}
+ scaleX={stageScale}
+ scaleY={stageScale}
+ draggable
+ onWheel={handleWheel}
+ onDragStart={() => {
+ if (stageRef.current) {
+ stageRef.current.container().style.cursor = "grabbing";
+ }
+ }}
+ onDragEnd={() => {
+ if (stageRef.current) {
+ stageRef.current.container().style.cursor = "grab";
+ }
+ }}
+ ref={(node) => {
+ stageRef.current = node;
+ if (node) {
+ node.container().style.cursor = "grab";
+ }
+ }}
+ >
+ {/* Background layer: grid, origin, hoop */}
+ <Layer>
+ {pesData && (
+ <>
+ <Grid
+ gridSize={100}
+ bounds={pesData.bounds}
+ machineInfo={machineInfo}
+ />
+ <Origin />
+ {machineInfo && <Hoop machineInfo={machineInfo} />}
+ </>
+ )}
+ </Layer>
+
+ {/* Pattern layer: draggable stitches and bounds */}
+ <Layer>
+ {pesData && (
+ <Group
+ name="pattern-group"
+ draggable={!patternUploaded && !isUploading}
+ x={localPatternOffset.x}
+ y={localPatternOffset.y}
+ onDragEnd={handlePatternDragEnd}
+ onMouseEnter={(e) => {
+ const stage = e.target.getStage();
+ if (stage && !patternUploaded && !isUploading)
+ stage.container().style.cursor = "move";
+ }}
+ onMouseLeave={(e) => {
+ const stage = e.target.getStage();
+ if (stage && !patternUploaded && !isUploading)
+ stage.container().style.cursor = "grab";
+ }}
+ >
+ <Stitches
+ stitches={pesData.penStitches.stitches.map(
+ (s, i): [number, number, number, number] => {
+ // Convert PEN stitch format {x, y, flags, isJump} to PES format [x, y, cmd, colorIndex]
+ const cmd = s.isJump ? 0x10 : 0; // MOVE flag if jump
+ const colorIndex =
+ pesData.penStitches.colorBlocks.find(
+ (b) => i >= b.startStitch && i <= b.endStitch,
+ )?.colorIndex ?? 0;
+ return [s.x, s.y, cmd, colorIndex];
+ },
+ )}
+ pesData={pesData}
+ currentStitchIndex={sewingProgress?.currentStitch || 0}
+ showProgress={patternUploaded || isUploading}
+ />
+ <PatternBounds bounds={pesData.bounds} />
+ </Group>
+ )}
+ </Layer>
+
+ {/* Current position layer */}
+ <Layer>
+ {pesData &&
+ pesData.penStitches &&
+ sewingProgress &&
+ sewingProgress.currentStitch > 0 && (
+ <Group x={localPatternOffset.x} y={localPatternOffset.y}>
+ <CurrentPosition
+ currentStitchIndex={sewingProgress.currentStitch}
+ stitches={pesData.penStitches.stitches.map(
+ (s, i): [number, number, number, number] => {
+ const cmd = s.isJump ? 0x10 : 0;
+ const colorIndex =
+ pesData.penStitches.colorBlocks.find(
+ (b) => i >= b.startStitch && i <= b.endStitch,
+ )?.colorIndex ?? 0;
+ return [s.x, s.y, cmd, colorIndex];
+ },
+ )}
+ />
+ </Group>
+ )}
+ </Layer>
+ </Stage>
+ )}
+
+ {/* Placeholder overlay when no pattern is loaded */}
+ {!pesData && (
+ <div className="flex items-center justify-center h-full text-gray-600 dark:text-gray-400 italic">
+ Load a PES file to preview the pattern
+ </div>
+ )}
+
+ {/* Pattern info overlays */}
+ {pesData && (
+ <>
+ {/* Thread Legend Overlay */}
+ <div className="absolute top-2 sm:top-2.5 left-2 sm:left-2.5 bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm p-2 sm:p-2.5 rounded-lg shadow-lg z-10 max-w-[150px] sm:max-w-[180px] lg:max-w-[200px]">
+ <h4 className="m-0 mb-1.5 sm:mb-2 text-xs font-semibold text-gray-900 dark:text-gray-100 border-b border-gray-300 dark:border-gray-600 pb-1 sm:pb-1.5">
+ Colors
+ </h4>
+ {pesData.uniqueColors.map((color, idx) => {
+ // Primary metadata: brand and catalog number
+ const primaryMetadata = [
+ color.brand,
+ color.catalogNumber ? `#${color.catalogNumber}` : null,
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ // Secondary metadata: chart and description
+ const secondaryMetadata = [color.chart, color.description]
+ .filter(Boolean)
+ .join(" ");
+
+ return (
+ <div
+ key={idx}
+ className="flex items-start gap-1.5 sm:gap-2 mb-1 sm:mb-1.5 last:mb-0"
+ >
+ <div
+ className="w-3 h-3 sm:w-4 sm:h-4 rounded border border-black dark:border-gray-300 flex-shrink-0 mt-0.5"
+ style={{ backgroundColor: color.hex }}
+ />
+ <div className="flex-1 min-w-0">
+ <div className="text-xs font-semibold text-gray-900 dark:text-gray-100">
+ Color {idx + 1}
+ </div>
+ {(primaryMetadata || secondaryMetadata) && (
+ <div className="text-xs text-gray-600 dark:text-gray-400 leading-tight mt-0.5 break-words">
+ {primaryMetadata}
+ {primaryMetadata && secondaryMetadata && (
+ <span className="mx-1">•</span>
+ )}
+ {secondaryMetadata}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+
+ {/* Pattern Offset Indicator */}
+ <div
+ className={`absolute bottom-16 sm:bottom-20 right-2 sm:right-5 backdrop-blur-sm p-2 sm:p-2.5 px-2.5 sm:px-3.5 rounded-lg shadow-lg z-[11] min-w-[160px] sm:min-w-[180px] transition-colors ${
+ patternUploaded
+ ? "bg-amber-50/95 dark:bg-amber-900/80 border-2 border-amber-300 dark:border-amber-600"
+ : "bg-white/95 dark:bg-gray-800/95"
+ }`}
+ >
+ <div className="flex items-center justify-between mb-1">
+ <div className="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wider">
+ Pattern Position:
+ </div>
+ {patternUploaded && (
+ <div className="flex items-center gap-1 text-amber-600 dark:text-amber-400">
+ <LockClosedIcon className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
+ <span className="text-xs font-bold">LOCKED</span>
+ </div>
+ )}
+ </div>
+ <div className="text-sm font-semibold text-primary-600 dark:text-primary-400 mb-1">
+ X: {(localPatternOffset.x / 10).toFixed(1)}mm, Y:{" "}
+ {(localPatternOffset.y / 10).toFixed(1)}mm
+ </div>
+ <div className="text-xs text-gray-600 dark:text-gray-400 italic">
+ {patternUploaded
+ ? "Pattern locked • Drag background to pan"
+ : "Drag pattern to move • Drag background to pan"}
+ </div>
+ </div>
+
+ {/* Zoom Controls Overlay */}
+ <div className="absolute bottom-2 sm:bottom-5 right-2 sm:right-5 flex gap-1.5 sm:gap-2 items-center bg-white/95 dark:bg-gray-800/95 backdrop-blur-sm px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg shadow-lg z-10">
+ <button
+ className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed"
+ onClick={handleCenterPattern}
+ disabled={!pesData || patternUploaded || isUploading}
+ title="Center Pattern in Hoop"
+ >
+ <ArrowsPointingInIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
+ </button>
+ <button
+ className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed"
+ onClick={handleZoomIn}
+ title="Zoom In"
+ >
+ <PlusIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
+ </button>
+ <span className="min-w-[40px] sm:min-w-[50px] text-center text-sm font-semibold text-gray-900 dark:text-gray-100 select-none">
+ {Math.round(stageScale * 100)}%
+ </span>
+ <button
+ className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed"
+ onClick={handleZoomOut}
+ title="Zoom Out"
+ >
+ <MinusIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
+ </button>
+ <button
+ className="w-7 h-7 sm:w-8 sm:h-8 p-1 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 rounded cursor-pointer transition-all flex items-center justify-center hover:bg-primary-600 hover:text-white hover:border-primary-600 dark:hover:border-primary-600 hover:shadow-md hover:shadow-primary-600/30 disabled:opacity-50 disabled:cursor-not-allowed ml-1"
+ onClick={handleZoomReset}
+ title="Reset Zoom"
+ >
+ <ArrowPathIcon className="w-4 h-4 sm:w-5 sm:h-5 dark:text-gray-200" />
+ </button>
+ </div>
+ </>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/src/components/PatternInfo.tsx b/src/components/PatternInfo.tsx
index cbb3d17..94ec215 100644
--- a/src/components/PatternInfo.tsx
+++ b/src/components/PatternInfo.tsx
@@ -1,75 +1,88 @@
-import type { PesPatternData } from '../formats/import/pesImporter';
+import type { PesPatternData } from "../formats/import/pesImporter";
interface PatternInfoProps {
pesData: PesPatternData;
showThreadBlocks?: boolean;
}
-export function PatternInfo({ pesData, showThreadBlocks = false }: PatternInfoProps) {
+export function PatternInfo({
+ pesData,
+ showThreadBlocks = false,
+}: PatternInfoProps) {
return (
<>
<div className="grid grid-cols-3 gap-2 text-xs mb-2">
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">Size</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
- {((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{' '}
+ {((pesData.bounds.maxX - pesData.bounds.minX) / 10).toFixed(1)} x{" "}
{((pesData.bounds.maxY - pesData.bounds.minY) / 10).toFixed(1)} mm
</span>
</div>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
- <span className="text-gray-600 dark:text-gray-400 block">Stitches</span>
+ <span className="text-gray-600 dark:text-gray-400 block">
+ Stitches
+ </span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
- {pesData.penStitches?.stitches.length.toLocaleString() || pesData.stitchCount.toLocaleString()}
- {pesData.penStitches && pesData.penStitches.stitches.length !== pesData.stitchCount && (
- <span
- className="text-gray-500 dark:text-gray-500 font-normal ml-1"
- title="Input stitch count from PES file (lock stitches were added for machine compatibility)"
- >
- ({pesData.stitchCount.toLocaleString()})
- </span>
- )}
+ {pesData.penStitches?.stitches.length.toLocaleString() ||
+ pesData.stitchCount.toLocaleString()}
+ {pesData.penStitches &&
+ pesData.penStitches.stitches.length !== pesData.stitchCount && (
+ <span
+ className="text-gray-500 dark:text-gray-500 font-normal ml-1"
+ title="Input stitch count from PES file (lock stitches were added for machine compatibility)"
+ >
+ ({pesData.stitchCount.toLocaleString()})
+ </span>
+ )}
</span>
</div>
<div className="bg-gray-200 dark:bg-gray-700/50 p-2 rounded">
<span className="text-gray-600 dark:text-gray-400 block">
- {showThreadBlocks ? 'Colors / Blocks' : 'Colors'}
+ {showThreadBlocks ? "Colors / Blocks" : "Colors"}
</span>
<span className="font-semibold text-gray-900 dark:text-gray-100">
{showThreadBlocks
? `${pesData.uniqueColors.length} / ${pesData.threads.length}`
- : pesData.uniqueColors.length
- }
+ : pesData.uniqueColors.length}
</span>
</div>
</div>
<div className="flex items-center gap-2 mb-2">
- <span className="text-xs text-gray-600 dark:text-gray-400">Colors:</span>
+ <span className="text-xs text-gray-600 dark:text-gray-400">
+ Colors:
+ </span>
<div className="flex gap-1">
{pesData.uniqueColors.slice(0, 8).map((color, idx) => {
// Primary metadata: brand and catalog number
const primaryMetadata = [
color.brand,
- color.catalogNumber ? `#${color.catalogNumber}` : null
- ].filter(Boolean).join(" ");
+ color.catalogNumber ? `#${color.catalogNumber}` : null,
+ ]
+ .filter(Boolean)
+ .join(" ");
// Secondary metadata: chart and description
- const secondaryMetadata = [
- color.chart,
- color.description
- ].filter(Boolean).join(" ");
+ const secondaryMetadata = [color.chart, color.description]
+ .filter(Boolean)
+ .join(" ");
- const metadata = [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
+ const metadata = [primaryMetadata, secondaryMetadata]
+ .filter(Boolean)
+ .join(" • ");
// Show which thread blocks use this color in PatternSummaryCard
- const threadNumbers = color.threadIndices.map(i => i + 1).join(", ");
+ const threadNumbers = color.threadIndices
+ .map((i) => i + 1)
+ .join(", ");
const tooltipText = showThreadBlocks
- ? (metadata
- ? `Color ${idx + 1}: ${color.hex} - ${metadata}`
- : `Color ${idx + 1}: ${color.hex}`)
- : (metadata
- ? `Color ${idx + 1}: ${color.hex}\n${metadata}\nUsed in thread blocks: ${threadNumbers}`
- : `Color ${idx + 1}: ${color.hex}\nUsed in thread blocks: ${threadNumbers}`);
+ ? metadata
+ ? `Color ${idx + 1}: ${color.hex} - ${metadata}`
+ : `Color ${idx + 1}: ${color.hex}`
+ : metadata
+ ? `Color ${idx + 1}: ${color.hex}\n${metadata}\nUsed in thread blocks: ${threadNumbers}`
+ : `Color ${idx + 1}: ${color.hex}\nUsed in thread blocks: ${threadNumbers}`;
return (
<div
diff --git a/src/components/PatternPreviewPlaceholder.tsx b/src/components/PatternPreviewPlaceholder.tsx
index b46bdd2..9b13cad 100644
--- a/src/components/PatternPreviewPlaceholder.tsx
+++ b/src/components/PatternPreviewPlaceholder.tsx
@@ -1,7 +1,9 @@
export function PatternPreviewPlaceholder() {
return (
<div className="lg:h-full bg-white dark:bg-gray-800 p-6 rounded-lg shadow-md animate-fadeIn flex flex-col">
- <h2 className="text-base lg:text-lg font-semibold mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600 dark:text-white flex-shrink-0">Pattern Preview</h2>
+ <h2 className="text-base lg:text-lg font-semibold mb-4 pb-2 border-b-2 border-gray-300 dark:border-gray-600 dark:text-white flex-shrink-0">
+ Pattern Preview
+ </h2>
<div className="h-[400px] sm:h-[500px] lg:flex-1 flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-700 dark:to-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600 relative overflow-hidden">
{/* Decorative background pattern */}
<div className="absolute inset-0 opacity-5 dark:opacity-10">
@@ -12,18 +14,41 @@ export function PatternPreviewPlaceholder() {
<div className="text-center relative z-10">
<div className="relative inline-block mb-6">
- <svg className="w-28 h-28 mx-auto text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
+ <svg
+ className="w-28 h-28 mx-auto text-gray-300 dark:text-gray-600"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={1.5}
+ d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
+ />
</svg>
<div className="absolute -top-2 -right-2 w-8 h-8 bg-primary-100 dark:bg-primary-900 rounded-full flex items-center justify-center">
- <svg className="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
+ <svg
+ className="w-5 h-5 text-primary-600 dark:text-primary-400"
+ fill="none"
+ stroke="currentColor"
+ viewBox="0 0 24 24"
+ >
+ <path
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth={2}
+ d="M12 4v16m8-8H4"
+ />
</svg>
</div>
</div>
- <h3 className="text-gray-700 dark:text-gray-200 text-base lg:text-lg font-semibold mb-2">No Pattern Loaded</h3>
+ <h3 className="text-gray-700 dark:text-gray-200 text-base lg:text-lg font-semibold mb-2">
+ No Pattern Loaded
+ </h3>
<p className="text-gray-500 dark:text-gray-400 text-sm mb-4 max-w-sm mx-auto">
- Connect to your machine and choose a PES embroidery file to see your design preview
+ Connect to your machine and choose a PES embroidery file to see your
+ design preview
</p>
<div className="flex items-center justify-center gap-6 text-xs text-gray-400 dark:text-gray-500">
<div className="flex items-center gap-1">
diff --git a/src/components/PatternSummaryCard.tsx b/src/components/PatternSummaryCard.tsx
index cd809e0..d06cf5e 100644
--- a/src/components/PatternSummaryCard.tsx
+++ b/src/components/PatternSummaryCard.tsx
@@ -1,33 +1,26 @@
-import { useShallow } from 'zustand/react/shallow';
-import { useMachineStore } from '../stores/useMachineStore';
-import { usePatternStore } from '../stores/usePatternStore';
-import { canDeletePattern } from '../utils/machineStateHelpers';
-import { PatternInfo } from './PatternInfo';
-import { DocumentTextIcon, TrashIcon } from '@heroicons/react/24/solid';
+import { useShallow } from "zustand/react/shallow";
+import { useMachineStore } from "../stores/useMachineStore";
+import { usePatternStore } from "../stores/usePatternStore";
+import { canDeletePattern } from "../utils/machineStateHelpers";
+import { PatternInfo } from "./PatternInfo";
+import { DocumentTextIcon, TrashIcon } from "@heroicons/react/24/solid";
export function PatternSummaryCard() {
// Machine store
- const {
- machineStatus,
- isDeleting,
- deletePattern,
- } = useMachineStore(
+ const { machineStatus, isDeleting, deletePattern } = useMachineStore(
useShallow((state) => ({
machineStatus: state.machineStatus,
isDeleting: state.isDeleting,
deletePattern: state.deletePattern,
- }))
+ })),
);
// Pattern store
- const {
- pesData,
- currentFileName,
- } = usePatternStore(
+ const { pesData, currentFileName } = usePatternStore(
useShallow((state) => ({
pesData: state.pesData,
currentFileName: state.currentFileName,
- }))
+ })),
);
if (!pesData) return null;
@@ -38,8 +31,13 @@ export function PatternSummaryCard() {
<div className="flex items-start gap-3 mb-3">
<DocumentTextIcon className="w-6 h-6 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
- <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">Active Pattern</h3>
- <p className="text-xs text-gray-600 dark:text-gray-400 truncate" title={currentFileName}>
+ <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-1">
+ Active Pattern
+ </h3>
+ <p
+ className="text-xs text-gray-600 dark:text-gray-400 truncate"
+ title={currentFileName}
+ >
{currentFileName}
</p>
</div>
@@ -55,9 +53,24 @@ export function PatternSummaryCard() {
>
{isDeleting ? (
<>
- <svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+ <svg
+ className="w-3 h-3 animate-spin"
+ fill="none"
+ viewBox="0 0 24 24"
+ >
+ <circle
+ className="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ strokeWidth="4"
+ ></circle>
+ <path
+ className="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+ ></path>
</svg>
Deleting...
</>
diff --git a/src/components/ProgressMonitor.tsx b/src/components/ProgressMonitor.tsx
index 11c3692..5a82a40 100644
--- a/src/components/ProgressMonitor.tsx
+++ b/src/components/ProgressMonitor.tsx
@@ -1,7 +1,7 @@
import { useRef, useEffect, useState, useMemo } from "react";
-import { useShallow } from 'zustand/react/shallow';
-import { useMachineStore } from '../stores/useMachineStore';
-import { usePatternStore } from '../stores/usePatternStore';
+import { useShallow } from "zustand/react/shallow";
+import { useMachineStore } from "../stores/useMachineStore";
+import { usePatternStore } from "../stores/usePatternStore";
import {
CheckCircleIcon,
ArrowRightIcon,
@@ -42,7 +42,7 @@ export function ProgressMonitor() {
startMaskTrace: state.startMaskTrace,
startSewing: state.startSewing,
resumeSewing: state.resumeSewing,
- }))
+ })),
);
// Pattern store
@@ -59,14 +59,15 @@ export function ProgressMonitor() {
// Use PEN stitch count as fallback when machine reports 0 total stitches
const totalStitches = patternInfo
- ? (patternInfo.totalStitches === 0 && pesData?.penStitches
- ? pesData.penStitches.stitches.length
- : patternInfo.totalStitches)
+ ? patternInfo.totalStitches === 0 && pesData?.penStitches
+ ? pesData.penStitches.stitches.length
+ : patternInfo.totalStitches
: 0;
- const progressPercent = totalStitches > 0
- ? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100
- : 0;
+ const progressPercent =
+ totalStitches > 0
+ ? ((sewingProgress?.currentStitch || 0) / totalStitches) * 100
+ : 0;
// Calculate color block information from decoded penStitches
const colorBlocks = useMemo(() => {
@@ -116,7 +117,10 @@ export function ProgressMonitor() {
return { totalMinutes: 0, elapsedMinutes: 0 };
}
const result = calculatePatternTime(colorBlocks, currentStitch);
- return { totalMinutes: result.totalMinutes, elapsedMinutes: result.elapsedMinutes };
+ return {
+ totalMinutes: result.totalMinutes,
+ elapsedMinutes: result.elapsedMinutes,
+ };
}, [colorBlocks, currentStitch]);
// Auto-scroll to current block
@@ -132,7 +136,8 @@ export function ProgressMonitor() {
// Handle scroll to detect if at bottom
const handleColorBlocksScroll = () => {
if (colorBlocksScrollRef.current) {
- const { scrollTop, scrollHeight, clientHeight } = colorBlocksScrollRef.current;
+ const { scrollTop, scrollHeight, clientHeight } =
+ colorBlocksScrollRef.current;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5; // 5px threshold
setShowGradient(!isAtBottom);
}
@@ -149,8 +154,8 @@ export function ProgressMonitor() {
};
checkScrollable();
- window.addEventListener('resize', checkScrollable);
- return () => window.removeEventListener('resize', checkScrollable);
+ window.addEventListener("resize", checkScrollable);
+ return () => window.removeEventListener("resize", checkScrollable);
}, [colorBlocks]);
const stateIndicatorColors = {
@@ -300,113 +305,124 @@ export function ProgressMonitor() {
className="lg:absolute lg:inset-0 flex flex-col gap-2 lg:overflow-y-auto scroll-smooth pr-1 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-gray-100 dark:[&::-webkit-scrollbar-track]:bg-gray-700 [&::-webkit-scrollbar-thumb]:bg-primary-600 dark:[&::-webkit-scrollbar-thumb]:bg-primary-500 [&::-webkit-scrollbar-thumb]:rounded-full"
>
{colorBlocks.map((block, index) => {
- const isCompleted = currentStitch >= block.endStitch;
- const isCurrent = index === currentBlockIndex;
-
- // Calculate progress within current block
- let blockProgress = 0;
- if (isCurrent) {
- blockProgress =
- ((currentStitch - block.startStitch) / block.stitchCount) *
- 100;
- } else if (isCompleted) {
- blockProgress = 100;
- }
-
- return (
- <div
- key={index}
- ref={isCurrent ? currentBlockRef : null}
- className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
- isCompleted
- ? "border-success-600 bg-success-50 dark:bg-success-900/20"
- : isCurrent
- ? "border-accent-600 bg-accent-50 dark:bg-accent-900/20 shadow-lg shadow-accent-600/20 animate-pulseGlow"
- : "border-gray-200 dark:border-gray-600 bg-gray-300 dark:bg-gray-800/50 opacity-70"
- }`}
- role="listitem"
- aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? "completed" : isCurrent ? "in progress" : "pending"}`}
- >
- <div className="flex items-center gap-2.5">
- {/* Color swatch */}
- <div
- className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
- style={{
- backgroundColor: block.threadHex,
- ...(isCurrent && { borderColor: "#9333ea" }),
- }}
- title={`Thread color: ${block.threadHex}`}
- aria-label={`Thread color ${block.threadHex}`}
- />
-
- {/* Thread info */}
- <div className="flex-1 min-w-0">
- <div className="font-semibold text-xs text-gray-900 dark:text-gray-100">
- Thread {block.colorIndex + 1}
- {(block.threadBrand || block.threadChart || block.threadDescription || block.threadCatalogNumber) && (
- <span className="font-normal text-gray-600 dark:text-gray-400">
- {" "}
- (
- {(() => {
- // Primary metadata: brand and catalog number
- const primaryMetadata = [
- block.threadBrand,
- block.threadCatalogNumber ? `#${block.threadCatalogNumber}` : null
- ].filter(Boolean).join(" ");
-
- // Secondary metadata: chart and description
- const secondaryMetadata = [
- block.threadChart,
- block.threadDescription
- ].filter(Boolean).join(" ");
-
- return [primaryMetadata, secondaryMetadata].filter(Boolean).join(" • ");
- })()}
- )
- </span>
- )}
- </div>
- <div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
- {block.stitchCount.toLocaleString()} stitches
+ const isCompleted = currentStitch >= block.endStitch;
+ const isCurrent = index === currentBlockIndex;
+
+ // Calculate progress within current block
+ let blockProgress = 0;
+ if (isCurrent) {
+ blockProgress =
+ ((currentStitch - block.startStitch) / block.stitchCount) *
+ 100;
+ } else if (isCompleted) {
+ blockProgress = 100;
+ }
+
+ return (
+ <div
+ key={index}
+ ref={isCurrent ? currentBlockRef : null}
+ className={`p-2.5 rounded-lg border-2 transition-all duration-300 ${
+ isCompleted
+ ? "border-success-600 bg-success-50 dark:bg-success-900/20"
+ : isCurrent
+ ? "border-accent-600 bg-accent-50 dark:bg-accent-900/20 shadow-lg shadow-accent-600/20 animate-pulseGlow"
+ : "border-gray-200 dark:border-gray-600 bg-gray-300 dark:bg-gray-800/50 opacity-70"
+ }`}
+ role="listitem"
+ aria-label={`Thread ${block.colorIndex + 1}, ${block.stitchCount} stitches, ${isCompleted ? "completed" : isCurrent ? "in progress" : "pending"}`}
+ >
+ <div className="flex items-center gap-2.5">
+ {/* Color swatch */}
+ <div
+ className="w-7 h-7 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-md flex-shrink-0"
+ style={{
+ backgroundColor: block.threadHex,
+ ...(isCurrent && { borderColor: "#9333ea" }),
+ }}
+ title={`Thread color: ${block.threadHex}`}
+ aria-label={`Thread color ${block.threadHex}`}
+ />
+
+ {/* Thread info */}
+ <div className="flex-1 min-w-0">
+ <div className="font-semibold text-xs text-gray-900 dark:text-gray-100">
+ Thread {block.colorIndex + 1}
+ {(block.threadBrand ||
+ block.threadChart ||
+ block.threadDescription ||
+ block.threadCatalogNumber) && (
+ <span className="font-normal text-gray-600 dark:text-gray-400">
+ {" "}
+ (
+ {(() => {
+ // Primary metadata: brand and catalog number
+ const primaryMetadata = [
+ block.threadBrand,
+ block.threadCatalogNumber
+ ? `#${block.threadCatalogNumber}`
+ : null,
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ // Secondary metadata: chart and description
+ const secondaryMetadata = [
+ block.threadChart,
+ block.threadDescription,
+ ]
+ .filter(Boolean)
+ .join(" ");
+
+ return [primaryMetadata, secondaryMetadata]
+ .filter(Boolean)
+ .join(" • ");
+ })()}
+ )
+ </span>
+ )}
+ </div>
+ <div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
+ {block.stitchCount.toLocaleString()} stitches
+ </div>
</div>
+
+ {/* Status icon */}
+ {isCompleted ? (
+ <CheckCircleIcon
+ className="w-5 h-5 text-success-600 flex-shrink-0"
+ aria-label="Completed"
+ />
+ ) : isCurrent ? (
+ <ArrowRightIcon
+ className="w-5 h-5 text-accent-600 flex-shrink-0 animate-pulse"
+ aria-label="In progress"
+ />
+ ) : (
+ <CircleStackIcon
+ className="w-5 h-5 text-gray-400 flex-shrink-0"
+ aria-label="Pending"
+ />
+ )}
</div>
- {/* Status icon */}
- {isCompleted ? (
- <CheckCircleIcon
- className="w-5 h-5 text-success-600 flex-shrink-0"
- aria-label="Completed"
- />
- ) : isCurrent ? (
- <ArrowRightIcon
- className="w-5 h-5 text-accent-600 flex-shrink-0 animate-pulse"
- aria-label="In progress"
- />
- ) : (
- <CircleStackIcon
- className="w-5 h-5 text-gray-400 flex-shrink-0"
- aria-label="Pending"
- />
+ {/* Progress bar for current block */}
+ {isCurrent && (
+ <div className="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
+ <div
+ className="h-full bg-accent-600 dark:bg-accent-500 transition-all duration-300 rounded-full"
+ style={{ width: `${blockProgress}%` }}
+ role="progressbar"
+ aria-valuenow={Math.round(blockProgress)}
+ aria-valuemin={0}
+ aria-valuemax={100}
+ aria-label={`${Math.round(blockProgress)}% complete`}
+ />
+ </div>
)}
</div>
-
- {/* Progress bar for current block */}
- {isCurrent && (
- <div className="mt-2 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
- <div
- className="h-full bg-accent-600 dark:bg-accent-500 transition-all duration-300 rounded-full"
- style={{ width: `${blockProgress}%` }}
- role="progressbar"
- aria-valuenow={Math.round(blockProgress)}
- aria-valuemin={0}
- aria-valuemax={100}
- aria-label={`${Math.round(blockProgress)}% complete`}
- />
- </div>
- )}
- </div>
- );
- })}
+ );
+ })}
</div>
{/* Gradient overlay to indicate more content below - only on desktop and when not at bottom */}
{showGradient && (
diff --git a/src/components/SkeletonLoader.tsx b/src/components/SkeletonLoader.tsx
index a4582e7..a7447c9 100644
--- a/src/components/SkeletonLoader.tsx
+++ b/src/components/SkeletonLoader.tsx
@@ -1,15 +1,19 @@
interface SkeletonLoaderProps {
className?: string;
- variant?: 'text' | 'rect' | 'circle';
+ variant?: "text" | "rect" | "circle";
}
-export function SkeletonLoader({ className = '', variant = 'rect' }: SkeletonLoaderProps) {
- const baseClasses = 'animate-pulse bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 bg-[length:200%_100%]';
+export function SkeletonLoader({
+ className = "",
+ variant = "rect",
+}: SkeletonLoaderProps) {
+ const baseClasses =
+ "animate-pulse bg-gradient-to-r from-gray-200 via-gray-300 to-gray-200 dark:from-gray-700 dark:via-gray-600 dark:to-gray-700 bg-[length:200%_100%]";
const variantClasses = {
- text: 'h-4 rounded',
- rect: 'rounded-lg',
- circle: 'rounded-full'
+ text: "h-4 rounded",
+ rect: "rounded-lg",
+ circle: "rounded-full",
};
return (
@@ -29,9 +33,24 @@ export function PatternCanvasSkeleton() {
<div className="relative w-24 h-24 mx-auto">
<SkeletonLoader className="w-24 h-24" variant="circle" />
<div className="absolute inset-0 flex items-center justify-center">
- <svg className="w-12 h-12 text-gray-400 dark:text-gray-500 animate-spin" fill="none" viewBox="0 0 24 24">
- <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
- <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
+ <svg
+ className="w-12 h-12 text-gray-400 dark:text-gray-500 animate-spin"
+ fill="none"
+ viewBox="0 0 24 24"
+ >
+ <circle
+ className="opacity-25"
+ cx="12"
+ cy="12"
+ r="10"
+ stroke="currentColor"
+ strokeWidth="4"
+ ></circle>
+ <path
+ className="opacity-75"
+ fill="currentColor"
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
+ ></path>
</svg>
</div>
</div>
diff --git a/src/components/WorkflowStepper.tsx b/src/components/WorkflowStepper.tsx
index da0d176..150c0ec 100644
--- a/src/components/WorkflowStepper.tsx
+++ b/src/components/WorkflowStepper.tsx
@@ -1,10 +1,14 @@
-import { useState, useRef, useEffect } from 'react';
-import { useShallow } from 'zustand/react/shallow';
-import { useMachineStore, usePatternUploaded } from '../stores/useMachineStore';
-import { usePatternStore } from '../stores/usePatternStore';
-import { CheckCircleIcon, InformationCircleIcon, ExclamationTriangleIcon } from '@heroicons/react/24/solid';
-import { MachineStatus } from '../types/machine';
-import { getErrorDetails, hasError } from '../utils/errorCodeHelpers';
+import { useState, useRef, useEffect } from "react";
+import { useShallow } from "zustand/react/shallow";
+import { useMachineStore, usePatternUploaded } from "../stores/useMachineStore";
+import { usePatternStore } from "../stores/usePatternStore";
+import {
+ CheckCircleIcon,
+ InformationCircleIcon,
+ ExclamationTriangleIcon,
+} from "@heroicons/react/24/solid";
+import { MachineStatus } from "../types/machine";
+import { getErrorDetails, hasError } from "../utils/errorCodeHelpers";
interface Step {
id: number;
@@ -13,14 +17,14 @@ interface Step {
}
const steps: Step[] = [
- { id: 1, label: 'Connect', description: 'Connect to machine' },
- { id: 2, label: 'Home Machine', description: 'Initialize hoop position' },
- { id: 3, label: 'Load Pattern', description: 'Choose PES file' },
- { id: 4, label: 'Upload', description: 'Upload to machine' },
- { id: 5, label: 'Mask Trace', description: 'Trace pattern area' },
- { id: 6, label: 'Start Sewing', description: 'Begin embroidery' },
- { id: 7, label: 'Monitor', description: 'Watch progress' },
- { id: 8, label: 'Complete', description: 'Finish and remove' },
+ { id: 1, label: "Connect", description: "Connect to machine" },
+ { id: 2, label: "Home Machine", description: "Initialize hoop position" },
+ { id: 3, label: "Load Pattern", description: "Choose PES file" },
+ { id: 4, label: "Upload", description: "Upload to machine" },
+ { id: 5, label: "Mask Trace", description: "Trace pattern area" },
+ { id: 6, label: "Start Sewing", description: "Begin embroidery" },
+ { id: 7, label: "Monitor", description: "Watch progress" },
+ { id: 8, label: "Complete", description: "Finish and remove" },
];
// Helper function to get guide content for a step
@@ -29,7 +33,7 @@ function getGuideContent(
machineStatus: MachineStatus,
hasError: boolean,
errorCode?: number,
- errorMessage?: string
+ errorMessage?: string,
) {
// Check for errors first
if (hasError) {
@@ -37,19 +41,22 @@ function getGuideContent(
if (errorDetails?.isInformational) {
return {
- type: 'info' as const,
+ type: "info" as const,
title: errorDetails.title,
description: errorDetails.description,
- items: errorDetails.solutions || []
+ items: errorDetails.solutions || [],
};
}
return {
- type: 'error' as const,
- title: errorDetails?.title || 'Error Occurred',
- description: errorDetails?.description || errorMessage || 'An error occurred. Please check the machine and try again.',
+ type: "error" as const,
+ title: errorDetails?.title || "Error Occurred",
+ description:
+ errorDetails?.description ||
+ errorMessage ||
+ "An error occurred. Please check the machine and try again.",
items: errorDetails?.solutions || [],
- errorCode
+ errorCode,
};
}
@@ -57,156 +64,166 @@ function getGuideContent(
switch (stepId) {
case 1:
return {
- type: 'info' as const,
- title: 'Step 1: Connect to Machine',
- description: 'To get started, connect to your Brother embroidery machine via Bluetooth.',
+ type: "info" as const,
+ title: "Step 1: Connect to Machine",
+ description:
+ "To get started, connect to your Brother embroidery machine via Bluetooth.",
items: [
- 'Make sure your machine is powered on',
- 'Enable Bluetooth on your machine',
- 'Click the "Connect to Machine" button below'
- ]
+ "Make sure your machine is powered on",
+ "Enable Bluetooth on your machine",
+ 'Click the "Connect to Machine" button below',
+ ],
};
case 2:
return {
- type: 'info' as const,
- title: 'Step 2: Home Machine',
- description: 'The hoop needs to be removed and an initial homing procedure must be performed.',
+ type: "info" as const,
+ title: "Step 2: Home Machine",
+ description:
+ "The hoop needs to be removed and an initial homing procedure must be performed.",
items: [
- 'Remove the embroidery hoop from the machine completely',
- 'Press the Accept button on the machine',
- 'Wait for the machine to complete its initialization (homing)',
- 'Once initialization is complete, reattach the hoop',
- 'The machine should now recognize the hoop correctly'
- ]
+ "Remove the embroidery hoop from the machine completely",
+ "Press the Accept button on the machine",
+ "Wait for the machine to complete its initialization (homing)",
+ "Once initialization is complete, reattach the hoop",
+ "The machine should now recognize the hoop correctly",
+ ],
};
case 3:
return {
- type: 'info' as const,
- title: 'Step 3: Load Your Pattern',
- description: 'Choose a PES embroidery file from your computer to preview and upload.',
+ type: "info" as const,
+ title: "Step 3: Load Your Pattern",
+ description:
+ "Choose a PES embroidery file from your computer to preview and upload.",
items: [
'Click "Choose PES File" in the Pattern File section',
- 'Select your embroidery design (.pes file)',
- 'Review the pattern preview on the right',
- 'You can drag the pattern to adjust its position'
- ]
+ "Select your embroidery design (.pes file)",
+ "Review the pattern preview on the right",
+ "You can drag the pattern to adjust its position",
+ ],
};
case 4:
return {
- type: 'info' as const,
- title: 'Step 4: Upload Pattern to Machine',
- description: 'Send your pattern to the embroidery machine to prepare for sewing.',
+ type: "info" as const,
+ title: "Step 4: Upload Pattern to Machine",
+ description:
+ "Send your pattern to the embroidery machine to prepare for sewing.",
items: [
- 'Review the pattern preview to ensure it\'s positioned correctly',
- 'Check the pattern size matches your hoop',
+ "Review the pattern preview to ensure it's positioned correctly",
+ "Check the pattern size matches your hoop",
'Click "Upload to Machine" when ready',
- 'Wait for the upload to complete (this may take a minute)'
- ]
+ "Wait for the upload to complete (this may take a minute)",
+ ],
};
case 5:
// Check machine status for substates
if (machineStatus === MachineStatus.MASK_TRACE_LOCK_WAIT) {
return {
- type: 'warning' as const,
- title: 'Machine Action Required',
- description: 'The machine is ready to trace the pattern outline.',
+ type: "warning" as const,
+ title: "Machine Action Required",
+ description: "The machine is ready to trace the pattern outline.",
items: [
- 'Press the button on your machine to confirm and start the mask trace',
- 'Ensure the hoop is properly attached',
- 'Make sure the needle area is clear'
- ]
+ "Press the button on your machine to confirm and start the mask trace",
+ "Ensure the hoop is properly attached",
+ "Make sure the needle area is clear",
+ ],
};
}
if (machineStatus === MachineStatus.MASK_TRACING) {
return {
- type: 'progress' as const,
- title: 'Mask Trace In Progress',
- description: 'The machine is tracing the pattern boundary. Please wait...',
+ type: "progress" as const,
+ title: "Mask Trace In Progress",
+ description:
+ "The machine is tracing the pattern boundary. Please wait...",
items: [
- 'Watch the machine trace the outline',
- 'Verify the pattern fits within your hoop',
- 'Do not interrupt the machine'
- ]
+ "Watch the machine trace the outline",
+ "Verify the pattern fits within your hoop",
+ "Do not interrupt the machine",
+ ],
};
}
return {
- type: 'info' as const,
- title: 'Step 5: Start Mask Trace',
- description: 'The mask trace helps the machine understand the pattern boundaries.',
+ type: "info" as const,
+ title: "Step 5: Start Mask Trace",
+ description:
+ "The mask trace helps the machine understand the pattern boundaries.",
items: [
'Click "Start Mask Trace" button in the Sewing Progress section',
- 'The machine will trace the pattern outline',
- 'This ensures the hoop is positioned correctly'
- ]
+ "The machine will trace the pattern outline",
+ "This ensures the hoop is positioned correctly",
+ ],
};
case 6:
return {
- type: 'success' as const,
- title: 'Step 6: Ready to Sew!',
- description: 'The machine is ready to begin embroidering your pattern.',
+ type: "success" as const,
+ title: "Step 6: Ready to Sew!",
+ description: "The machine is ready to begin embroidering your pattern.",
items: [
- 'Verify your thread colors are correct',
- 'Ensure the fabric is properly hooped',
- 'Click "Start Sewing" when ready'
- ]
+ "Verify your thread colors are correct",
+ "Ensure the fabric is properly hooped",
+ 'Click "Start Sewing" when ready',
+ ],
};
case 7:
// Check for substates
if (machineStatus === MachineStatus.COLOR_CHANGE_WAIT) {
return {
- type: 'warning' as const,
- title: 'Thread Change Required',
- description: 'The machine needs a different thread color to continue.',
+ type: "warning" as const,
+ title: "Thread Change Required",
+ description:
+ "The machine needs a different thread color to continue.",
items: [
- 'Check the color blocks section to see which thread is needed',
- 'Change to the correct thread color',
- 'Press the button on your machine to resume sewing'
- ]
+ "Check the color blocks section to see which thread is needed",
+ "Change to the correct thread color",
+ "Press the button on your machine to resume sewing",
+ ],
};
}
- if (machineStatus === MachineStatus.PAUSE ||
- machineStatus === MachineStatus.STOP ||
- machineStatus === MachineStatus.SEWING_INTERRUPTION) {
+ if (
+ machineStatus === MachineStatus.PAUSE ||
+ machineStatus === MachineStatus.STOP ||
+ machineStatus === MachineStatus.SEWING_INTERRUPTION
+ ) {
return {
- type: 'warning' as const,
- title: 'Sewing Paused',
- description: 'The embroidery has been paused or interrupted.',
+ type: "warning" as const,
+ title: "Sewing Paused",
+ description: "The embroidery has been paused or interrupted.",
items: [
- 'Check if everything is okay with the machine',
+ "Check if everything is okay with the machine",
'Click "Resume Sewing" when ready to continue',
- 'The machine will pick up where it left off'
- ]
+ "The machine will pick up where it left off",
+ ],
};
}
return {
- type: 'progress' as const,
- title: 'Step 7: Sewing In Progress',
- description: 'Your embroidery is being stitched. Monitor the progress below.',
+ type: "progress" as const,
+ title: "Step 7: Sewing In Progress",
+ description:
+ "Your embroidery is being stitched. Monitor the progress below.",
items: [
- 'Watch the progress bar and current stitch count',
- 'The machine will pause when a color change is needed',
- 'Do not leave the machine unattended'
- ]
+ "Watch the progress bar and current stitch count",
+ "The machine will pause when a color change is needed",
+ "Do not leave the machine unattended",
+ ],
};
case 8:
return {
- type: 'success' as const,
- title: 'Step 8: Embroidery Complete!',
- description: 'Your embroidery is finished. Great work!',
+ type: "success" as const,
+ title: "Step 8: Embroidery Complete!",
+ description: "Your embroidery is finished. Great work!",
items: [
- 'Remove the hoop from the machine',
- 'Press the Accept button on the machine',
- 'Carefully remove your finished embroidery',
- 'Trim any jump stitches or loose threads',
- 'Click "Delete Pattern" to start a new project'
- ]
+ "Remove the hoop from the machine",
+ "Press the Accept button on the machine",
+ "Carefully remove your finished embroidery",
+ "Trim any jump stitches or loose threads",
+ 'Click "Delete Pattern" to start a new project',
+ ],
};
default:
@@ -214,7 +231,12 @@ function getGuideContent(
}
}
-function getCurrentStep(machineStatus: MachineStatus, isConnected: boolean, hasPattern: boolean, patternUploaded: boolean): number {
+function getCurrentStep(
+ machineStatus: MachineStatus,
+ isConnected: boolean,
+ hasPattern: boolean,
+ patternUploaded: boolean,
+): number {
if (!isConnected) return 1;
// Check if machine needs homing (Initial state)
@@ -262,23 +284,26 @@ export function WorkflowStepper() {
isConnected: state.isConnected,
machineError: state.machineError,
error: state.error,
- }))
+ })),
);
// Pattern store
- const {
- pesData,
- } = usePatternStore(
+ const { pesData } = usePatternStore(
useShallow((state) => ({
pesData: state.pesData,
- }))
+ })),
);
// Derived state: pattern is uploaded if machine has pattern info
const patternUploaded = usePatternUploaded();
const hasPattern = pesData !== null;
const hasErrorFlag = hasError(machineError);
- const currentStep = getCurrentStep(machineStatus, isConnected, hasPattern, patternUploaded);
+ const currentStep = getCurrentStep(
+ machineStatus,
+ isConnected,
+ hasPattern,
+ patternUploaded,
+ );
const [showPopover, setShowPopover] = useState(false);
const [popoverStep, setPopoverStep] = useState<number | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
@@ -287,10 +312,13 @@ export function WorkflowStepper() {
// Close popover when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
- if (popoverRef.current && !popoverRef.current.contains(event.target as Node)) {
+ if (
+ popoverRef.current &&
+ !popoverRef.current.contains(event.target as Node)
+ ) {
// Check if click was on a step circle
- const clickedStep = Object.values(stepRefs.current).find(ref =>
- ref?.contains(event.target as Node)
+ const clickedStep = Object.values(stepRefs.current).find((ref) =>
+ ref?.contains(event.target as Node),
);
if (!clickedStep) {
setShowPopover(false);
@@ -299,8 +327,9 @@ export function WorkflowStepper() {
};
if (showPopover) {
- document.addEventListener('mousedown', handleClickOutside);
- return () => document.removeEventListener('mousedown', handleClickOutside);
+ document.addEventListener("mousedown", handleClickOutside);
+ return () =>
+ document.removeEventListener("mousedown", handleClickOutside);
}
}, [showPopover]);
@@ -318,16 +347,23 @@ export function WorkflowStepper() {
};
return (
- <div className="relative max-w-5xl mx-auto mt-2 lg:mt-4" role="navigation" aria-label="Workflow progress">
+ <div
+ className="relative max-w-5xl mx-auto mt-2 lg:mt-4"
+ role="navigation"
+ aria-label="Workflow progress"
+ >
{/* Progress bar background */}
- <div className="absolute top-4 lg:top-5 left-0 right-0 h-0.5 lg:h-1 bg-primary-400/20 dark:bg-primary-600/20 rounded-full" style={{ left: '16px', right: '16px' }} />
+ <div
+ className="absolute top-4 lg:top-5 left-0 right-0 h-0.5 lg:h-1 bg-primary-400/20 dark:bg-primary-600/20 rounded-full"
+ style={{ left: "16px", right: "16px" }}
+ />
{/* Progress bar fill */}
<div
className="absolute top-4 lg:top-5 left-0 h-0.5 lg:h-1 bg-gradient-to-r from-success-500 to-primary-500 dark:from-success-600 dark:to-primary-600 transition-all duration-500 rounded-full"
style={{
- left: '16px',
- width: `calc(${((currentStep - 1) / (steps.length - 1)) * 100}% - 16px)`
+ left: "16px",
+ width: `calc(${((currentStep - 1) / (steps.length - 1)) * 100}% - 16px)`,
}}
role="progressbar"
aria-valuenow={currentStep}
@@ -349,26 +385,31 @@ export function WorkflowStepper() {
className="flex flex-col items-center"
style={{ flex: 1 }}
role="listitem"
- aria-current={isCurrent ? 'step' : undefined}
+ aria-current={isCurrent ? "step" : undefined}
>
{/* Step circle */}
<div
- ref={(el) => { stepRefs.current[step.id] = el; }}
+ ref={(el) => {
+ stepRefs.current[step.id] = el;
+ }}
onClick={() => handleStepClick(step.id)}
className={`
w-8 h-8 lg:w-10 lg:h-10 rounded-full flex items-center justify-center font-bold text-xs transition-all duration-300 border-2 shadow-md
- ${step.id <= currentStep ? 'cursor-pointer hover:scale-110' : 'cursor-not-allowed'}
- ${isComplete ? 'bg-success-500 dark:bg-success-600 border-success-400 dark:border-success-500 text-white shadow-success-500/30 dark:shadow-success-600/30' : ''}
- ${isCurrent ? 'bg-primary-600 dark:bg-primary-700 border-primary-500 dark:border-primary-600 text-white scale-105 lg:scale-110 shadow-primary-600/40 dark:shadow-primary-700/40 ring-2 ring-primary-300 dark:ring-primary-500 ring-offset-2 dark:ring-offset-gray-900' : ''}
- ${isUpcoming ? 'bg-primary-700 dark:bg-primary-800 border-primary-500/30 dark:border-primary-600/30 text-primary-200/70 dark:text-primary-300/70' : ''}
- ${showPopover && popoverStep === step.id ? 'ring-4 ring-white dark:ring-gray-800' : ''}
+ ${step.id <= currentStep ? "cursor-pointer hover:scale-110" : "cursor-not-allowed"}
+ ${isComplete ? "bg-success-500 dark:bg-success-600 border-success-400 dark:border-success-500 text-white shadow-success-500/30 dark:shadow-success-600/30" : ""}
+ ${isCurrent ? "bg-primary-600 dark:bg-primary-700 border-primary-500 dark:border-primary-600 text-white scale-105 lg:scale-110 shadow-primary-600/40 dark:shadow-primary-700/40 ring-2 ring-primary-300 dark:ring-primary-500 ring-offset-2 dark:ring-offset-gray-900" : ""}
+ ${isUpcoming ? "bg-primary-700 dark:bg-primary-800 border-primary-500/30 dark:border-primary-600/30 text-primary-200/70 dark:text-primary-300/70" : ""}
+ ${showPopover && popoverStep === step.id ? "ring-4 ring-white dark:ring-gray-800" : ""}
`}
- aria-label={`${step.label}: ${isComplete ? 'completed' : isCurrent ? 'current' : 'upcoming'}. Click for details.`}
+ aria-label={`${step.label}: ${isComplete ? "completed" : isCurrent ? "current" : "upcoming"}. Click for details.`}
role="button"
tabIndex={step.id <= currentStep ? 0 : -1}
>
{isComplete ? (
- <CheckCircleIcon className="w-5 h-5 lg:w-6 lg:h-6" aria-hidden="true" />
+ <CheckCircleIcon
+ className="w-5 h-5 lg:w-6 lg:h-6"
+ aria-hidden="true"
+ />
) : (
step.id
)}
@@ -376,9 +417,15 @@ export function WorkflowStepper() {
{/* Step label */}
<div className="mt-1 lg:mt-2 text-center">
- <div className={`text-xs font-semibold leading-tight ${
- isCurrent ? 'text-white' : isComplete ? 'text-success-200 dark:text-success-300' : 'text-primary-300/70 dark:text-primary-400/70'
- }`}>
+ <div
+ className={`text-xs font-semibold leading-tight ${
+ isCurrent
+ ? "text-white"
+ : isComplete
+ ? "text-success-200 dark:text-success-300"
+ : "text-primary-300/70 dark:text-primary-400/70"
+ }`}
+ >
{step.label}
</div>
</div>
@@ -396,74 +443,113 @@ export function WorkflowStepper() {
aria-label="Step guidance"
>
{(() => {
- const content = getGuideContent(popoverStep, machineStatus, hasErrorFlag, machineError, errorMessage || undefined);
+ const content = getGuideContent(
+ popoverStep,
+ machineStatus,
+ hasErrorFlag,
+ machineError,
+ errorMessage || undefined,
+ );
if (!content) return null;
const colorClasses = {
- info: 'bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500',
- success: 'bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500',
- warning: 'bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500',
- error: 'bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500',
- progress: 'bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500'
+ info: "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
+ success:
+ "bg-success-50 dark:bg-success-900/95 border-success-600 dark:border-success-500",
+ warning:
+ "bg-warning-50 dark:bg-warning-900/95 border-warning-600 dark:border-warning-500",
+ error:
+ "bg-danger-50 dark:bg-danger-900/95 border-danger-600 dark:border-danger-500",
+ progress:
+ "bg-info-50 dark:bg-info-900/95 border-info-600 dark:border-info-500",
};
const iconColorClasses = {
- info: 'text-info-600 dark:text-info-400',
- success: 'text-success-600 dark:text-success-400',
- warning: 'text-warning-600 dark:text-warning-400',
- error: 'text-danger-600 dark:text-danger-400',
- progress: 'text-info-600 dark:text-info-400'
+ info: "text-info-600 dark:text-info-400",
+ success: "text-success-600 dark:text-success-400",
+ warning: "text-warning-600 dark:text-warning-400",
+ error: "text-danger-600 dark:text-danger-400",
+ progress: "text-info-600 dark:text-info-400",
};
const textColorClasses = {
- info: 'text-info-900 dark:text-info-200',
- success: 'text-success-900 dark:text-success-200',
- warning: 'text-warning-900 dark:text-warning-200',
- error: 'text-danger-900 dark:text-danger-200',
- progress: 'text-info-900 dark:text-info-200'
+ info: "text-info-900 dark:text-info-200",
+ success: "text-success-900 dark:text-success-200",
+ warning: "text-warning-900 dark:text-warning-200",
+ error: "text-danger-900 dark:text-danger-200",
+ progress: "text-info-900 dark:text-info-200",
};
const descColorClasses = {
- info: 'text-info-800 dark:text-info-300',
- success: 'text-success-800 dark:text-success-300',
- warning: 'text-warning-800 dark:text-warning-300',
- error: 'text-danger-800 dark:text-danger-300',
- progress: 'text-info-800 dark:text-info-300'
+ info: "text-info-800 dark:text-info-300",
+ success: "text-success-800 dark:text-success-300",
+ warning: "text-warning-800 dark:text-warning-300",
+ error: "text-danger-800 dark:text-danger-300",
+ progress: "text-info-800 dark:text-info-300",
};
const listColorClasses = {
- info: 'text-blue-700 dark:text-blue-300',
- success: 'text-green-700 dark:text-green-300',
- warning: 'text-yellow-700 dark:text-yellow-300',
- error: 'text-red-700 dark:text-red-300',
- progress: 'text-cyan-700 dark:text-cyan-300'
+ info: "text-blue-700 dark:text-blue-300",
+ success: "text-green-700 dark:text-green-300",
+ warning: "text-yellow-700 dark:text-yellow-300",
+ error: "text-red-700 dark:text-red-300",
+ progress: "text-cyan-700 dark:text-cyan-300",
};
- const Icon = content.type === 'error' ? ExclamationTriangleIcon : InformationCircleIcon;
+ const Icon =
+ content.type === "error"
+ ? ExclamationTriangleIcon
+ : InformationCircleIcon;
return (
- <div className={`${colorClasses[content.type]} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}>
+ <div
+ className={`${colorClasses[content.type]} border-l-4 p-4 rounded-lg shadow-xl backdrop-blur-sm`}
+ >
<div className="flex items-start gap-3">
- <Icon className={`w-6 h-6 ${iconColorClasses[content.type]} flex-shrink-0 mt-0.5`} />
+ <Icon
+ className={`w-6 h-6 ${iconColorClasses[content.type]} flex-shrink-0 mt-0.5`}
+ />
<div className="flex-1">
- <h3 className={`text-base font-semibold ${textColorClasses[content.type]} mb-2`}>
+ <h3
+ className={`text-base font-semibold ${textColorClasses[content.type]} mb-2`}
+ >
{content.title}
</h3>
- <p className={`text-sm ${descColorClasses[content.type]} mb-3`}>
+ <p
+ className={`text-sm ${descColorClasses[content.type]} mb-3`}
+ >
{content.description}
</p>
{content.items && content.items.length > 0 && (
- <ul className={`list-disc list-inside text-sm ${listColorClasses[content.type]} space-y-1`}>
+ <ul
+ className={`list-disc list-inside text-sm ${listColorClasses[content.type]} space-y-1`}
+ >
{content.items.map((item, index) => (
- <li key={index} className="pl-2" dangerouslySetInnerHTML={{ __html: item.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') }} />
+ <li
+ key={index}
+ className="pl-2"
+ dangerouslySetInnerHTML={{
+ __html: item.replace(
+ /\*\*(.*?)\*\*/g,
+ "<strong>$1</strong>",
+ ),
+ }}
+ />
))}
</ul>
)}
- {content.type === 'error' && content.errorCode !== undefined && (
- <p className={`text-xs ${descColorClasses[content.type]} mt-3 font-mono`}>
- Error Code: 0x{content.errorCode.toString(16).toUpperCase().padStart(2, '0')}
- </p>
- )}
+ {content.type === "error" &&
+ content.errorCode !== undefined && (
+ <p
+ className={`text-xs ${descColorClasses[content.type]} mt-3 font-mono`}
+ >
+ Error Code: 0x
+ {content.errorCode
+ .toString(16)
+ .toUpperCase()
+ .padStart(2, "0")}
+ </p>
+ )}
</div>
</div>
</div>
diff --git a/src/formats/import/client.ts b/src/formats/import/client.ts
index ef4bd53..f580537 100644
--- a/src/formats/import/client.ts
+++ b/src/formats/import/client.ts
@@ -1,12 +1,12 @@
-import type { WorkerMessage, WorkerResponse } from './worker';
-import PatternConverterWorker from './worker?worker';
-import { decodePenData } from '../pen/decoder';
-import type { DecodedPenData } from '../pen/types';
+import type { WorkerMessage, WorkerResponse } from "./worker";
+import PatternConverterWorker from "./worker?worker";
+import { decodePenData } from "../pen/decoder";
+import type { DecodedPenData } from "../pen/types";
-export type PyodideState = 'not_loaded' | 'loading' | 'ready' | 'error';
+export type PyodideState = "not_loaded" | "loading" | "ready" | "error";
export interface PesPatternData {
- stitches: number[][]; // Original PES stitches (for reference)
+ stitches: number[][]; // Original PES stitches (for reference)
threads: Array<{
color: number;
hex: string;
@@ -24,7 +24,7 @@ export interface PesPatternData {
chart: string | null;
threadIndices: number[];
}>;
- penData: Uint8Array; // Raw PEN bytes sent to machine
+ penData: Uint8Array; // Raw PEN bytes sent to machine
penStitches: DecodedPenData; // Decoded PEN stitches (for rendering)
colorCount: number;
stitchCount: number;
@@ -40,7 +40,7 @@ export type ProgressCallback = (progress: number, step: string) => void;
class PatternConverterClient {
private worker: Worker | null = null;
- private state: PyodideState = 'not_loaded';
+ private state: PyodideState = "not_loaded";
private error: string | null = null;
private initPromise: Promise<void> | null = null;
private progressCallbacks: Set<ProgressCallback> = new Set();
@@ -64,7 +64,7 @@ class PatternConverterClient {
*/
async initialize(onProgress?: ProgressCallback): Promise<void> {
// If already ready, return immediately
- if (this.state === 'ready') {
+ if (this.state === "ready") {
return;
}
@@ -78,13 +78,13 @@ class PatternConverterClient {
// Create worker if it doesn't exist
if (!this.worker) {
- console.log('[PatternConverterClient] Creating worker...');
+ console.log("[PatternConverterClient] Creating worker...");
try {
this.worker = new PatternConverterWorker();
- console.log('[PatternConverterClient] Worker created successfully');
+ console.log("[PatternConverterClient] Worker created successfully");
this.setupWorkerListeners();
} catch (err) {
- console.error('[PatternConverterClient] Failed to create worker:', err);
+ console.error("[PatternConverterClient] Failed to create worker:", err);
throw err;
}
}
@@ -95,7 +95,7 @@ class PatternConverterClient {
}
// Start initialization
- this.state = 'loading';
+ this.state = "loading";
this.error = null;
this.initPromise = new Promise<void>((resolve, reject) => {
@@ -103,44 +103,55 @@ class PatternConverterClient {
const message = event.data;
switch (message.type) {
- case 'INIT_PROGRESS':
+ case "INIT_PROGRESS":
// Notify all progress callbacks
this.progressCallbacks.forEach((callback) => {
callback(message.progress, message.step);
});
break;
- case 'INIT_COMPLETE':
- this.state = 'ready';
+ case "INIT_COMPLETE":
+ this.state = "ready";
this.progressCallbacks.clear();
- this.worker?.removeEventListener('message', handleMessage);
+ this.worker?.removeEventListener("message", handleMessage);
resolve();
break;
- case 'INIT_ERROR':
- this.state = 'error';
+ case "INIT_ERROR":
+ this.state = "error";
this.error = message.error;
this.progressCallbacks.clear();
- this.worker?.removeEventListener('message', handleMessage);
+ this.worker?.removeEventListener("message", handleMessage);
reject(new Error(message.error));
break;
}
};
- this.worker?.addEventListener('message', handleMessage);
+ this.worker?.addEventListener("message", handleMessage);
// Send initialization message with asset URLs
// Resolve URLs relative to the current page location
- const baseURL = window.location.origin + window.location.pathname.replace(/\/[^/]*$/, '/');
- const pyodideIndexURL = new URL('assets/', baseURL).href;
- const pystitchWheelURL = new URL('pystitch-1.0.0-py3-none-any.whl', baseURL).href;
-
- console.log('[PatternConverterClient] Base URL:', baseURL);
- console.log('[PatternConverterClient] Pyodide index URL:', pyodideIndexURL);
- console.log('[PatternConverterClient] Pystitch wheel URL:', pystitchWheelURL);
+ const baseURL =
+ window.location.origin +
+ window.location.pathname.replace(/\/[^/]*$/, "/");
+ const pyodideIndexURL = new URL("assets/", baseURL).href;
+ const pystitchWheelURL = new URL(
+ "pystitch-1.0.0-py3-none-any.whl",
+ baseURL,
+ ).href;
+
+ console.log("[PatternConverterClient] Base URL:", baseURL);
+ console.log(
+ "[PatternConverterClient] Pyodide index URL:",
+ pyodideIndexURL,
+ );
+ console.log(
+ "[PatternConverterClient] Pystitch wheel URL:",
+ pystitchWheelURL,
+ );
const initMessage: WorkerMessage = {
- type: 'INITIALIZE',
+ type: "INITIALIZE",
pyodideIndexURL,
pystitchWheelURL,
};
@@ -155,19 +166,21 @@ class PatternConverterClient {
*/
async convertPesToPen(file: File): Promise<PesPatternData> {
// Ensure worker is initialized
- if (this.state !== 'ready') {
- throw new Error('Pyodide worker not initialized. Call initialize() first.');
+ if (this.state !== "ready") {
+ throw new Error(
+ "Pyodide worker not initialized. Call initialize() first.",
+ );
}
if (!this.worker) {
- throw new Error('Worker not available');
+ throw new Error("Worker not available");
}
return new Promise<PesPatternData>((resolve, reject) => {
// Store reference to worker for TypeScript null checking
const worker = this.worker;
if (!worker) {
- reject(new Error('Worker not available'));
+ reject(new Error("Worker not available"));
return;
}
@@ -175,14 +188,20 @@ class PatternConverterClient {
const message = event.data;
switch (message.type) {
- case 'CONVERT_COMPLETE': {
- worker.removeEventListener('message', handleMessage);
+ case "CONVERT_COMPLETE": {
+ worker.removeEventListener("message", handleMessage);
// Convert penData array back to Uint8Array
const penData = new Uint8Array(message.data.penData);
// Decode the PEN data to get stitches for rendering
const penStitches = decodePenData(penData);
- console.log('[PatternConverter] Decoded PEN data:', penStitches.stitches.length, 'stitches,', penStitches.colorBlocks.length, 'colors');
+ console.log(
+ "[PatternConverter] Decoded PEN data:",
+ penStitches.stitches.length,
+ "stitches,",
+ penStitches.colorBlocks.length,
+ "colors",
+ );
const result: PesPatternData = {
...message.data,
@@ -193,28 +212,28 @@ class PatternConverterClient {
break;
}
- case 'CONVERT_ERROR':
- worker.removeEventListener('message', handleMessage);
+ case "CONVERT_ERROR":
+ worker.removeEventListener("message", handleMessage);
reject(new Error(message.error));
break;
}
};
- worker.addEventListener('message', handleMessage);
+ worker.addEventListener("message", handleMessage);
// Read file as ArrayBuffer and send to worker
const reader = new FileReader();
reader.onload = () => {
const convertMessage: WorkerMessage = {
- type: 'CONVERT_PES',
+ type: "CONVERT_PES",
fileData: reader.result as ArrayBuffer,
fileName: file.name,
};
worker.postMessage(convertMessage);
};
reader.onerror = () => {
- worker.removeEventListener('message', handleMessage);
- reject(new Error('Failed to read file'));
+ worker.removeEventListener("message", handleMessage);
+ reject(new Error("Failed to read file"));
};
reader.readAsArrayBuffer(file);
});
@@ -226,16 +245,16 @@ class PatternConverterClient {
private setupWorkerListeners() {
if (!this.worker) return;
- this.worker.addEventListener('error', (event) => {
- console.error('[PyodideWorkerClient] Worker error:', event);
- this.state = 'error';
- this.error = event.message || 'Worker error';
+ this.worker.addEventListener("error", (event) => {
+ console.error("[PyodideWorkerClient] Worker error:", event);
+ this.state = "error";
+ this.error = event.message || "Worker error";
});
- this.worker.addEventListener('messageerror', (event) => {
- console.error('[PyodideWorkerClient] Worker message error:', event);
- this.state = 'error';
- this.error = 'Failed to deserialize worker message';
+ this.worker.addEventListener("messageerror", (event) => {
+ console.error("[PyodideWorkerClient] Worker message error:", event);
+ this.state = "error";
+ this.error = "Failed to deserialize worker message";
});
}
@@ -247,7 +266,7 @@ class PatternConverterClient {
this.worker.terminate();
this.worker = null;
}
- this.state = 'not_loaded';
+ this.state = "not_loaded";
this.error = null;
this.initPromise = null;
this.progressCallbacks.clear();
diff --git a/src/formats/import/constants.ts b/src/formats/import/constants.ts
index 01e13ba..4d74f1c 100644
--- a/src/formats/import/constants.ts
+++ b/src/formats/import/constants.ts
@@ -8,10 +8,10 @@
*/
// Stitch type flags (bitmasks - can be combined)
-export const STITCH = 0x00; // Regular stitch (no flags)
-export const MOVE = 0x10; // Jump/move stitch (move without stitching)
-export const JUMP = MOVE; // Alias: JUMP is the same as MOVE
-export const TRIM = 0x20; // Trim thread command
+export const STITCH = 0x00; // Regular stitch (no flags)
+export const MOVE = 0x10; // Jump/move stitch (move without stitching)
+export const JUMP = MOVE; // Alias: JUMP is the same as MOVE
+export const TRIM = 0x20; // Trim thread command
export const COLOR_CHANGE = 0x40; // Color change command
-export const STOP = 0x80; // Stop command
-export const END = 0x100; // End of pattern
+export const STOP = 0x80; // Stop command
+export const END = 0x100; // End of pattern
diff --git a/src/formats/import/pesImporter.ts b/src/formats/import/pesImporter.ts
index ae1b6b3..64fb710 100644
--- a/src/formats/import/pesImporter.ts
+++ b/src/formats/import/pesImporter.ts
@@ -11,7 +11,6 @@ export async function convertPesToPen(file: File): Promise<PesPatternData> {
return await patternConverterClient.convertPesToPen(file);
}
-
/**
* Get thread color from pattern data
*/
diff --git a/src/formats/import/worker.ts b/src/formats/import/worker.ts
index 6b0cc42..86d5366 100644
--- a/src/formats/import/worker.ts
+++ b/src/formats/import/worker.ts
@@ -1,24 +1,19 @@
-import { loadPyodide, type PyodideInterface } from 'pyodide';
-import {
- STITCH,
- MOVE,
- TRIM,
- END,
-} from './constants';
-import { encodeStitchesToPen } from '../pen/encoder';
+import { loadPyodide, type PyodideInterface } from "pyodide";
+import { STITCH, MOVE, TRIM, END } from "./constants";
+import { encodeStitchesToPen } from "../pen/encoder";
// Message types from main thread
export type WorkerMessage =
- | { type: 'INITIALIZE'; pyodideIndexURL?: string; pystitchWheelURL?: string }
- | { type: 'CONVERT_PES'; fileData: ArrayBuffer; fileName: string };
+ | { type: "INITIALIZE"; pyodideIndexURL?: string; pystitchWheelURL?: string }
+ | { type: "CONVERT_PES"; fileData: ArrayBuffer; fileName: string };
// Response types to main thread
export type WorkerResponse =
- | { type: 'INIT_PROGRESS'; progress: number; step: string }
- | { type: 'INIT_COMPLETE' }
- | { type: 'INIT_ERROR'; error: string }
+ | { type: "INIT_PROGRESS"; progress: number; step: string }
+ | { type: "INIT_COMPLETE" }
+ | { type: "INIT_ERROR"; error: string }
| {
- type: 'CONVERT_COMPLETE';
+ type: "CONVERT_COMPLETE";
data: {
stitches: number[][];
threads: Array<{
@@ -49,9 +44,9 @@ export type WorkerResponse =
};
};
}
- | { type: 'CONVERT_ERROR'; error: string };
+ | { type: "CONVERT_ERROR"; error: string };
-console.log('[PatternConverterWorker] Worker script loaded');
+console.log("[PatternConverterWorker] Worker script loaded");
let pyodide: PyodideInterface | null = null;
let isInitializing = false;
@@ -67,79 +62,82 @@ const jsEmbConstants = {
/**
* Initialize Pyodide with progress tracking
*/
-async function initializePyodide(pyodideIndexURL?: string, pystitchWheelURL?: string) {
+async function initializePyodide(
+ pyodideIndexURL?: string,
+ pystitchWheelURL?: string,
+) {
if (pyodide) {
return; // Already initialized
}
if (isInitializing) {
- throw new Error('Initialization already in progress');
+ throw new Error("Initialization already in progress");
}
isInitializing = true;
try {
self.postMessage({
- type: 'INIT_PROGRESS',
+ type: "INIT_PROGRESS",
progress: 0,
- step: 'Starting initialization...',
+ step: "Starting initialization...",
} as WorkerResponse);
- console.log('[PyodideWorker] Loading Pyodide runtime...');
+ console.log("[PyodideWorker] Loading Pyodide runtime...");
self.postMessage({
- type: 'INIT_PROGRESS',
+ type: "INIT_PROGRESS",
progress: 10,
- step: 'Loading Python runtime...',
+ step: "Loading Python runtime...",
} as WorkerResponse);
// Load Pyodide runtime
// Use provided URL or default to /assets/
- const indexURL = pyodideIndexURL || '/assets/';
- console.log('[PyodideWorker] Pyodide index URL:', indexURL);
+ const indexURL = pyodideIndexURL || "/assets/";
+ console.log("[PyodideWorker] Pyodide index URL:", indexURL);
pyodide = await loadPyodide({
indexURL: indexURL,
});
- console.log('[PyodideWorker] Pyodide runtime loaded');
+ console.log("[PyodideWorker] Pyodide runtime loaded");
self.postMessage({
- type: 'INIT_PROGRESS',
+ type: "INIT_PROGRESS",
progress: 70,
- step: 'Python runtime loaded',
+ step: "Python runtime loaded",
} as WorkerResponse);
self.postMessage({
- type: 'INIT_PROGRESS',
+ type: "INIT_PROGRESS",
progress: 75,
- step: 'Loading pystitch library...',
+ step: "Loading pystitch library...",
} as WorkerResponse);
// Load pystitch wheel
// Use provided URL or default
- const wheelURL = pystitchWheelURL || '/pystitch-1.0.0-py3-none-any.whl';
- console.log('[PyodideWorker] Pystitch wheel URL:', wheelURL);
+ const wheelURL = pystitchWheelURL || "/pystitch-1.0.0-py3-none-any.whl";
+ console.log("[PyodideWorker] Pystitch wheel URL:", wheelURL);
await pyodide.loadPackage(wheelURL);
- console.log('[PyodideWorker] pystitch library loaded');
+ console.log("[PyodideWorker] pystitch library loaded");
self.postMessage({
- type: 'INIT_PROGRESS',
+ type: "INIT_PROGRESS",
progress: 100,
- step: 'Ready!',
+ step: "Ready!",
} as WorkerResponse);
self.postMessage({
- type: 'INIT_COMPLETE',
+ type: "INIT_COMPLETE",
} as WorkerResponse);
} catch (err) {
- const errorMsg = err instanceof Error ? err.message : 'Unknown error';
- console.error('[PyodideWorker] Initialization error:', err);
+ const errorMsg = err instanceof Error ? err.message : "Unknown error";
+ console.error("[PyodideWorker] Initialization error:", err);
self.postMessage({
- type: 'INIT_ERROR',
+ type: "INIT_ERROR",
error: errorMsg,
} as WorkerResponse);
@@ -154,18 +152,18 @@ async function initializePyodide(pyodideIndexURL?: string, pystitchWheelURL?: st
*/
async function convertPesToPen(fileData: ArrayBuffer) {
if (!pyodide) {
- throw new Error('Pyodide not initialized');
+ throw new Error("Pyodide not initialized");
}
try {
// Register our JavaScript constants module for Python to import
- pyodide.registerJsModule('js_emb_constants', jsEmbConstants);
+ pyodide.registerJsModule("js_emb_constants", jsEmbConstants);
// Convert to Uint8Array
const uint8Array = new Uint8Array(fileData);
// Write file to Pyodide virtual filesystem
- const tempFileName = '/tmp/pattern.pes';
+ const tempFileName = "/tmp/pattern.pes";
pyodide.FS.writeFile(tempFileName, uint8Array);
// Read the pattern using PyStitch (same logic as original converter)
@@ -277,11 +275,11 @@ for i, stitch in enumerate(pattern.stitches):
// Extract stitches and validate
const stitches: number[][] = Array.from(
- data.stitches as ArrayLike<ArrayLike<number>>
+ data.stitches as ArrayLike<ArrayLike<number>>,
).map((stitch) => Array.from(stitch));
if (!stitches || stitches.length === 0) {
- throw new Error('Invalid PES file or no stitches found');
+ throw new Error("Invalid PES file or no stitches found");
}
// Extract thread data - preserve null values for unavailable metadata
@@ -301,27 +299,27 @@ for i, stitch in enumerate(pattern.stitches):
catalogNum !== undefined &&
catalogNum !== null &&
catalogNum !== -1 &&
- catalogNum !== '-1' &&
- catalogNum !== ''
+ catalogNum !== "-1" &&
+ catalogNum !== ""
? String(catalogNum)
: null;
return {
color: thread.color ?? 0,
- hex: thread.hex || '#000000',
+ hex: thread.hex || "#000000",
catalogNumber: normalizedCatalog,
- brand: thread.brand && thread.brand !== '' ? thread.brand : null,
+ brand: thread.brand && thread.brand !== "" ? thread.brand : null,
description:
- thread.description && thread.description !== ''
+ thread.description && thread.description !== ""
? thread.description
: null,
- chart: thread.chart && thread.chart !== '' ? thread.chart : null,
+ chart: thread.chart && thread.chart !== "" ? thread.chart : null,
};
});
// Encode stitches to PEN format using the extracted encoder
- console.log('[patternConverter] Encoding stitches to PEN format...');
- console.log(' - Input stitches:', stitches);
+ console.log("[patternConverter] Encoding stitches to PEN format...");
+ console.log(" - Input stitches:", stitches);
const { penBytes: penStitches, bounds } = encodeStitchesToPen(stitches);
const { minX, maxX, minY, maxY } = bounds;
@@ -352,13 +350,13 @@ for i, stitch in enumerate(pattern.stitches):
description: string | null;
chart: string | null;
threadIndices: number[];
- }>
+ }>,
);
// Calculate PEN stitch count (should match what machine will count)
const penStitchCount = penStitches.length / 4;
- console.log('[patternConverter] PEN encoding complete:');
+ console.log("[patternConverter] PEN encoding complete:");
console.log(` - PyStitch stitches: ${stitches.length}`);
console.log(` - PEN bytes: ${penStitches.length}`);
console.log(` - PEN stitches (bytes/4): ${penStitchCount}`);
@@ -366,7 +364,7 @@ for i, stitch in enumerate(pattern.stitches):
// Post result back to main thread
self.postMessage({
- type: 'CONVERT_COMPLETE',
+ type: "CONVERT_COMPLETE",
data: {
stitches,
threads,
@@ -383,11 +381,11 @@ for i, stitch in enumerate(pattern.stitches):
},
} as WorkerResponse);
} catch (err) {
- const errorMsg = err instanceof Error ? err.message : 'Unknown error';
- console.error('[PyodideWorker] Conversion error:', err);
+ const errorMsg = err instanceof Error ? err.message : "Unknown error";
+ console.error("[PyodideWorker] Conversion error:", err);
self.postMessage({
- type: 'CONVERT_ERROR',
+ type: "CONVERT_ERROR",
error: errorMsg,
} as WorkerResponse);
@@ -398,26 +396,32 @@ for i, stitch in enumerate(pattern.stitches):
// Handle messages from main thread
self.onmessage = async (event: MessageEvent<WorkerMessage>) => {
const message = event.data;
- console.log('[PatternConverterWorker] Received message:', message.type);
+ console.log("[PatternConverterWorker] Received message:", message.type);
try {
switch (message.type) {
- case 'INITIALIZE':
- console.log('[PatternConverterWorker] Starting initialization...');
- await initializePyodide(message.pyodideIndexURL, message.pystitchWheelURL);
+ case "INITIALIZE":
+ console.log("[PatternConverterWorker] Starting initialization...");
+ await initializePyodide(
+ message.pyodideIndexURL,
+ message.pystitchWheelURL,
+ );
break;
- case 'CONVERT_PES':
- console.log('[PatternConverterWorker] Starting PES conversion...');
+ case "CONVERT_PES":
+ console.log("[PatternConverterWorker] Starting PES conversion...");
await convertPesToPen(message.fileData);
break;
default:
- console.error('[PatternConverterWorker] Unknown message type:', message);
+ console.error(
+ "[PatternConverterWorker] Unknown message type:",
+ message,
+ );
}
} catch (err) {
- console.error('[PatternConverterWorker] Error handling message:', err);
+ console.error("[PatternConverterWorker] Error handling message:", err);
}
};
-console.log('[PatternConverterWorker] Message handler registered');
+console.log("[PatternConverterWorker] Message handler registered");
diff --git a/src/formats/pen/decoder.ts b/src/formats/pen/decoder.ts
index 50e910f..a61854f 100644
--- a/src/formats/pen/decoder.ts
+++ b/src/formats/pen/decoder.ts
@@ -5,13 +5,13 @@
* The PEN format uses absolute coordinates shifted left by 3 bits, with flags in the low 3 bits.
*/
-import type { DecodedPenStitch, DecodedPenData, PenColorBlock } from './types';
+import type { DecodedPenStitch, DecodedPenData, PenColorBlock } from "./types";
// PEN format flags
const PEN_FEED_DATA = 0x01; // Bit 0: Jump stitch (move without stitching)
-const PEN_CUT_DATA = 0x02; // Bit 1: Trim/cut thread command
+const PEN_CUT_DATA = 0x02; // Bit 1: Trim/cut thread command
const PEN_COLOR_END = 0x03; // Last stitch before color change
-const PEN_DATA_END = 0x05; // Last stitch of entire pattern
+const PEN_DATA_END = 0x05; // Last stitch of entire pattern
/**
* Decode a single PEN stitch (4 bytes) into coordinates and flags
@@ -22,7 +22,7 @@ const PEN_DATA_END = 0x05; // Last stitch of entire pattern
*/
export function decodePenStitch(
bytes: Uint8Array | number[],
- offset: number
+ offset: number,
): DecodedPenStitch {
const xLow = bytes[offset];
const xHigh = bytes[offset + 1];
@@ -37,14 +37,14 @@ export function decodePenStitch(
const yFlags = yRaw & 0x07;
// Clear flags and shift right to get actual coordinates
- const xClean = xRaw & 0xFFF8;
- const yClean = yRaw & 0xFFF8;
+ const xClean = xRaw & 0xfff8;
+ const yClean = yRaw & 0xfff8;
// Convert to signed 16-bit
let xSigned = xClean;
let ySigned = yClean;
- if (xSigned > 0x7FFF) xSigned = xSigned - 0x10000;
- if (ySigned > 0x7FFF) ySigned = ySigned - 0x10000;
+ if (xSigned > 0x7fff) xSigned = xSigned - 0x10000;
+ if (ySigned > 0x7fff) ySigned = ySigned - 0x10000;
// Shift right by 3 to get actual coordinates
const x = xSigned >> 3;
@@ -76,9 +76,13 @@ export function decodePenStitch(
* @param bytes PEN format byte array
* @returns Array of decoded stitches
*/
-export function decodeAllPenStitches(bytes: Uint8Array | number[]): DecodedPenStitch[] {
+export function decodeAllPenStitches(
+ bytes: Uint8Array | number[],
+): DecodedPenStitch[] {
if (bytes.length < 4 || bytes.length % 4 !== 0) {
- throw new Error(`Invalid PEN data size: ${bytes.length} bytes (must be multiple of 4)`);
+ throw new Error(
+ `Invalid PEN data size: ${bytes.length} bytes (must be multiple of 4)`,
+ );
}
const stitches: DecodedPenStitch[] = [];
@@ -169,9 +173,15 @@ export function decodePenData(data: Uint8Array): DecodedPenData {
* @param stitchIndex Index of the stitch
* @returns Color index, or -1 if not found
*/
-export function getStitchColor(penData: DecodedPenData, stitchIndex: number): number {
+export function getStitchColor(
+ penData: DecodedPenData,
+ stitchIndex: number,
+): number {
for (const block of penData.colorBlocks) {
- if (stitchIndex >= block.startStitchIndex && stitchIndex <= block.endStitchIndex) {
+ if (
+ stitchIndex >= block.startStitchIndex &&
+ stitchIndex <= block.endStitchIndex
+ ) {
return block.colorIndex;
}
}
diff --git a/src/formats/pen/encoder.test.ts b/src/formats/pen/encoder.test.ts
index 189be15..57ffd76 100644
--- a/src/formats/pen/encoder.test.ts
+++ b/src/formats/pen/encoder.test.ts
@@ -1,51 +1,51 @@
-import { describe, it, expect } from 'vitest';
+import { describe, it, expect } from "vitest";
import {
encodeStitchPosition,
calculateLockDirection,
generateLockStitches,
encodeStitchesToPen,
- LOCK_STITCH_JUMP_SIZE
-} from './encoder';
-import { decodeAllPenStitches } from './decoder';
-import { STITCH, MOVE, TRIM, END } from '../import/constants';
+ LOCK_STITCH_JUMP_SIZE,
+} from "./encoder";
+import { decodeAllPenStitches } from "./decoder";
+import { STITCH, MOVE, TRIM, END } from "../import/constants";
// PEN format flag constants for testing
const PEN_FEED_DATA = 0x01;
const PEN_CUT_DATA = 0x02;
-describe('encodeStitchPosition', () => {
- it('should encode position (0, 0) correctly', () => {
+describe("encodeStitchPosition", () => {
+ it("should encode position (0, 0) correctly", () => {
const result = encodeStitchPosition(0, 0);
expect(result).toEqual([0x00, 0x00, 0x00, 0x00]);
});
- it('should shift coordinates left by 3 bits', () => {
+ it("should shift coordinates left by 3 bits", () => {
// Position (1, 1) should become (8, 8) after shifting
const result = encodeStitchPosition(1, 1);
expect(result).toEqual([0x08, 0x00, 0x08, 0x00]);
});
- it('should handle negative coordinates', () => {
+ it("should handle negative coordinates", () => {
// -1 in 16-bit signed = 0xFFFF, shifted left 3 = 0xFFF8
const result = encodeStitchPosition(-1, -1);
- expect(result).toEqual([0xF8, 0xFF, 0xF8, 0xFF]);
+ expect(result).toEqual([0xf8, 0xff, 0xf8, 0xff]);
});
- it('should encode multi-byte coordinates correctly', () => {
+ it("should encode multi-byte coordinates correctly", () => {
// Position (128, 0) -> shifted = 1024 = 0x0400
const result = encodeStitchPosition(128, 0);
expect(result).toEqual([0x00, 0x04, 0x00, 0x00]);
});
- it('should round fractional coordinates', () => {
+ it("should round fractional coordinates", () => {
const result = encodeStitchPosition(1.5, 2.4);
// 2 << 3 = 16, 2 << 3 = 16
expect(result).toEqual([0x10, 0x00, 0x10, 0x00]);
});
});
-describe('calculateLockDirection', () => {
- it('should look ahead for forward direction', () => {
+describe("calculateLockDirection", () => {
+ it("should look ahead for forward direction", () => {
const stitches = [
[0, 0, STITCH, 0],
[10, 0, STITCH, 0],
@@ -62,7 +62,7 @@ describe('calculateLockDirection', () => {
expect(magnitude).toBeCloseTo(8.0, 1);
});
- it('should look backward for backward direction', () => {
+ it("should look backward for backward direction", () => {
const stitches = [
[0, 0, STITCH, 0],
[10, 0, STITCH, 0],
@@ -79,10 +79,10 @@ describe('calculateLockDirection', () => {
expect(magnitude).toBeCloseTo(8.0, 1);
});
- it('should skip MOVE stitches when accumulating', () => {
+ it("should skip MOVE stitches when accumulating", () => {
const stitches = [
[0, 0, STITCH, 0],
- [5, 0, MOVE, 0], // Should be skipped
+ [5, 0, MOVE, 0], // Should be skipped
[10, 0, STITCH, 0],
[15, 0, STITCH, 0],
];
@@ -93,10 +93,8 @@ describe('calculateLockDirection', () => {
expect(result.dirX).toBeGreaterThan(0);
});
- it('should return fallback diagonal for empty or short stitch sequences', () => {
- const stitches = [
- [0, 0, STITCH, 0],
- ];
+ it("should return fallback diagonal for empty or short stitch sequences", () => {
+ const stitches = [[0, 0, STITCH, 0]];
const result = calculateLockDirection(stitches, 0, true);
@@ -106,7 +104,7 @@ describe('calculateLockDirection', () => {
expect(result.dirY).toBeCloseTo(expectedMag, 1);
});
- it('should normalize accumulated vector to magnitude 8.0', () => {
+ it("should normalize accumulated vector to magnitude 8.0", () => {
const stitches = [
[0, 0, STITCH, 0],
[3, 4, STITCH, 0], // Distance = 5
@@ -124,7 +122,7 @@ describe('calculateLockDirection', () => {
expect(magnitude).toBeCloseTo(8.0, 1);
});
- it('should stop accumulating after reaching target length', () => {
+ it("should stop accumulating after reaching target length", () => {
// Create a long chain of stitches
const stitches = [
[0, 0, STITCH, 0],
@@ -144,13 +142,13 @@ describe('calculateLockDirection', () => {
});
});
-describe('generateLockStitches', () => {
- it('should generate 8 lock stitches (32 bytes)', () => {
+describe("generateLockStitches", () => {
+ it("should generate 8 lock stitches (32 bytes)", () => {
const result = generateLockStitches(0, 0, 8.0, 0);
expect(result.length).toBe(32); // 8 stitches * 4 bytes each
});
- it('should alternate between +dir and -dir', () => {
+ it("should alternate between +dir and -dir", () => {
const result = generateLockStitches(0, 0, 8.0, 0);
expect(result.length).toBe(32); // 8 stitches * 4 bytes
@@ -159,7 +157,7 @@ describe('generateLockStitches', () => {
expect(result2.length).toBe(32);
});
- it('should rotate stitches in the given direction', () => {
+ it("should rotate stitches in the given direction", () => {
// Direction pointing right (8, 0)
const result = generateLockStitches(0, 0, 8.0, 0);
@@ -176,8 +174,8 @@ describe('generateLockStitches', () => {
});
});
-describe('encodeStitchesToPen', () => {
- it('should encode a simple stitch sequence', () => {
+describe("encodeStitchesToPen", () => {
+ it("should encode a simple stitch sequence", () => {
const stitches = [
[0, 0, STITCH, 0],
[10, 0, STITCH, 0],
@@ -192,7 +190,7 @@ describe('encodeStitchesToPen', () => {
expect(result.bounds.maxX).toBe(20);
});
- it('should track bounds correctly', () => {
+ it("should track bounds correctly", () => {
const stitches = [
[10, 20, STITCH, 0],
[-5, 30, STITCH, 0],
@@ -208,7 +206,7 @@ describe('encodeStitchesToPen', () => {
expect(result.bounds.maxY).toBe(30);
});
- it('should mark the last stitch with DATA_END flag', () => {
+ it("should mark the last stitch with DATA_END flag", () => {
const stitches = [
[0, 0, STITCH, 0],
[10, 0, END, 0],
@@ -222,14 +220,14 @@ describe('encodeStitchesToPen', () => {
expect(xLow & 0x07).toBe(0x05); // DATA_END flag
});
- it('should handle color changes with lock stitches', () => {
+ it("should handle color changes with lock stitches", () => {
const stitches = [
- [0, 0, STITCH, 0], // Color 0
- [10, 0, STITCH, 0], // Color 0
- [20, 0, STITCH, 0], // Color 0 - last stitch before color change
- [20, 0, STITCH, 1], // Color 1 - first stitch of new color
- [30, 0, STITCH, 1], // Color 1
- [40, 0, END, 1], // Color 1 - last stitch
+ [0, 0, STITCH, 0], // Color 0
+ [10, 0, STITCH, 0], // Color 0
+ [20, 0, STITCH, 0], // Color 0 - last stitch before color change
+ [20, 0, STITCH, 1], // Color 1 - first stitch of new color
+ [30, 0, STITCH, 1], // Color 1
+ [40, 0, END, 1], // Color 1 - last stitch
];
const result = encodeStitchesToPen(stitches);
@@ -246,13 +244,13 @@ describe('encodeStitchesToPen', () => {
expect(result.penBytes.length).toBeGreaterThan(90); // Should have many bytes from lock stitches
});
- it('should encode color change sequence in correct order', () => {
+ it("should encode color change sequence in correct order", () => {
// Test the exact sequence of operations for a color change
const stitches = [
- [0, 0, STITCH, 0], // Color 0
- [10, 0, STITCH, 0], // Color 0 - last stitch before color change
- [10, 0, STITCH, 1], // Color 1 - first stitch (same position)
- [20, 0, STITCH | END, 1], // Color 1 - last stitch
+ [0, 0, STITCH, 0], // Color 0
+ [10, 0, STITCH, 0], // Color 0 - last stitch before color change
+ [10, 0, STITCH, 1], // Color 1 - first stitch (same position)
+ [20, 0, STITCH | END, 1], // Color 1 - last stitch
];
const result = encodeStitchesToPen(stitches);
@@ -325,11 +323,11 @@ describe('encodeStitchesToPen', () => {
expect(decoded[idx].isDataEnd).toBe(true);
});
- it('should encode color change with jump in correct order', () => {
+ it("should encode color change with jump in correct order", () => {
// Test color change when next color is at a different position
const stitches = [
- [0, 0, STITCH, 0], // Color 0
- [10, 0, STITCH, 0], // Color 0 - last before change
+ [0, 0, STITCH, 0], // Color 0
+ [10, 0, STITCH, 0], // Color 0 - last before change
[30, 10, STITCH, 1], // Color 1 - different position, requires jump
[40, 10, STITCH | END, 1],
];
@@ -372,13 +370,13 @@ describe('encodeStitchesToPen', () => {
expect(decoded[idx].y).toBe(10);
});
- it('should encode color change followed by explicit JUMP in correct order', () => {
+ it("should encode color change followed by explicit JUMP in correct order", () => {
// Test when PES data has a JUMP stitch immediately after color change
// This is a common pattern: color change, then jump to new location
const stitches = [
- [0, 0, STITCH, 0], // Color 0
- [10, 0, STITCH, 0], // Color 0 - last before change
- [50, 20, MOVE, 1], // Color 1 - JUMP to new location (50, 20)
+ [0, 0, STITCH, 0], // Color 0
+ [10, 0, STITCH, 0], // Color 0 - last before change
+ [50, 20, MOVE, 1], // Color 1 - JUMP to new location (50, 20)
[50, 20, STITCH, 1], // Color 1 - first actual stitch at new location
[60, 20, STITCH | END, 1],
];
@@ -443,12 +441,12 @@ describe('encodeStitchesToPen', () => {
expect(decoded[idx].isDataEnd).toBe(true);
});
- it('should handle long jumps with lock stitches and cut in correct order', () => {
+ it("should handle long jumps with lock stitches and cut in correct order", () => {
// Test the exact sequence for a long jump (distance > 50)
const stitches = [
[0, 0, STITCH, 0],
[10, 0, STITCH, 0],
- [100, 0, MOVE, 0], // Long jump (distance = 90 > 50)
+ [100, 0, MOVE, 0], // Long jump (distance = 90 > 50)
[110, 0, STITCH, 0],
[120, 0, STITCH | END, 0],
];
@@ -465,7 +463,7 @@ describe('encodeStitchesToPen', () => {
// 6. Stitch at (110, 0)
// 7. Stitch at (120, 0) with END flag
- let idx = 0;
+ let idx = 0;
// 1-2. First two stitches
expect(decoded[idx++].x).toBe(0);
@@ -508,10 +506,10 @@ describe('encodeStitchesToPen', () => {
expect(decoded[idx].isDataEnd).toBe(true);
});
- it('should encode MOVE flag for jump stitches', () => {
+ it("should encode MOVE flag for jump stitches", () => {
const stitches = [
[0, 0, STITCH, 0],
- [10, 0, MOVE, 0], // Short jump (no lock stitches)
+ [10, 0, MOVE, 0], // Short jump (no lock stitches)
[20, 0, END, 0],
];
@@ -525,10 +523,10 @@ describe('encodeStitchesToPen', () => {
expect(yLow & 0x01).toBe(0x01); // FEED_DATA flag
});
- it('should not include MOVE stitches in bounds calculation', () => {
+ it("should not include MOVE stitches in bounds calculation", () => {
const stitches = [
[0, 0, STITCH, 0],
- [100, 100, MOVE, 0], // Jump - should not affect bounds
+ [100, 100, MOVE, 0], // Jump - should not affect bounds
[10, 10, STITCH, 0],
[20, 20, STITCH | END, 0], // Last stitch with both STITCH and END flags
];
@@ -542,7 +540,7 @@ describe('encodeStitchesToPen', () => {
expect(result.bounds.maxY).toBe(20);
});
- it('should handle TRIM flag correctly', () => {
+ it("should handle TRIM flag correctly", () => {
const stitches = [
[0, 0, STITCH, 0],
[10, 0, TRIM, 0],
@@ -552,7 +550,7 @@ describe('encodeStitchesToPen', () => {
const result = encodeStitchesToPen(stitches);
const decoded = decodeAllPenStitches(result.penBytes);
- let idx = 0;
+ let idx = 0;
// Verify sequence:
// 1. Regular stitch at (0, 0)
@@ -574,7 +572,7 @@ describe('encodeStitchesToPen', () => {
expect(decoded[idx].isDataEnd).toBe(true);
});
- it('should handle empty stitch array', () => {
+ it("should handle empty stitch array", () => {
const stitches: number[][] = [];
const result = encodeStitchesToPen(stitches);
@@ -586,10 +584,8 @@ describe('encodeStitchesToPen', () => {
expect(result.bounds.maxY).toBe(0);
});
- it('should handle single stitch', () => {
- const stitches = [
- [5, 10, END, 0],
- ];
+ it("should handle single stitch", () => {
+ const stitches = [[5, 10, END, 0]];
const result = encodeStitchesToPen(stitches);
@@ -601,7 +597,7 @@ describe('encodeStitchesToPen', () => {
// END stitches update bounds (they're not MOVE stitches)
});
- it('should add DATA_END flag to last stitch even without END flag in input', () => {
+ it("should add DATA_END flag to last stitch even without END flag in input", () => {
// Test that the encoder automatically marks the last stitch with DATA_END
// even if the input stitches don't have an END flag
const stitches = [
@@ -623,7 +619,7 @@ describe('encodeStitchesToPen', () => {
expect(decoded[10].y).toBe(0);
});
- it('should add DATA_END flag when input has explicit END flag', () => {
+ it("should add DATA_END flag when input has explicit END flag", () => {
// Verify that END flag in input also results in DATA_END flag in output
const stitches = [
[0, 0, STITCH, 0],
@@ -640,7 +636,7 @@ describe('encodeStitchesToPen', () => {
expect(decoded[10].y).toBe(0);
});
- it('should add lock stitches at the very start of the pattern', () => {
+ it("should add lock stitches at the very start of the pattern", () => {
// Matching C# behavior: Nuihajime_TomeDataPlus is called when counter <= 2
// This adds starting lock stitches to secure the thread at pattern start
const stitches = [
diff --git a/src/formats/pen/encoder.ts b/src/formats/pen/encoder.ts
index 538cf62..489e34e 100644
--- a/src/formats/pen/encoder.ts
+++ b/src/formats/pen/encoder.ts
@@ -5,18 +5,18 @@
* The PEN format uses absolute coordinates shifted left by 3 bits, with flags in the low 3 bits.
*/
-import { MOVE, TRIM, END } from '../import/constants';
+import { MOVE, TRIM, END } from "../import/constants";
// PEN format flags for Brother machines
const PEN_FEED_DATA = 0x01; // Bit 0: Jump stitch (move without stitching)
-const PEN_CUT_DATA = 0x02; // Bit 1: Trim/cut thread command
+const PEN_CUT_DATA = 0x02; // Bit 1: Trim/cut thread command
const PEN_COLOR_END = 0x03; // Last stitch before color change
-const PEN_DATA_END = 0x05; // Last stitch of entire pattern
+const PEN_DATA_END = 0x05; // Last stitch of entire pattern
// Constants from PesxToPen.cs
const FEED_LENGTH = 50; // Long jump threshold requiring lock stitches and cut
-const TARGET_LENGTH = 8.0; // Target accumulated length for lock stitch direction
-const MAX_POINTS = 5; // Maximum points to accumulate for lock stitch direction
+const TARGET_LENGTH = 8.0; // Target accumulated length for lock stitch direction
+const MAX_POINTS = 5; // Maximum points to accumulate for lock stitch direction
export const LOCK_STITCH_JUMP_SIZE = 2.0;
const LOCK_STITCH_SCALE = LOCK_STITCH_JUMP_SIZE / 8.0; // Scale the magnitude-8 vector down to 4
@@ -45,12 +45,7 @@ export function encodeStitchPosition(x: number, y: number): number[] {
const xEnc = (Math.round(x) << 3) & 0xffff;
const yEnc = (Math.round(y) << 3) & 0xffff;
- return [
- xEnc & 0xff,
- (xEnc >> 8) & 0xff,
- yEnc & 0xff,
- (yEnc >> 8) & 0xff
- ];
+ return [xEnc & 0xff, (xEnc >> 8) & 0xff, yEnc & 0xff, (yEnc >> 8) & 0xff];
}
/**
@@ -70,7 +65,7 @@ export function encodeStitchPosition(x: number, y: number): number[] {
export function calculateLockDirection(
stitches: number[][],
currentIndex: number,
- lookAhead: boolean
+ lookAhead: boolean,
): { dirX: number; dirY: number } {
let accumulatedX = 0;
let accumulatedY = 0;
@@ -84,7 +79,7 @@ export function calculateLockDirection(
: Math.min(MAX_POINTS, currentIndex);
for (let i = 0; i < maxIterations; i++) {
- const idx = currentIndex + (step * (i + 1));
+ const idx = currentIndex + step * (i + 1);
if (idx < 0 || idx >= stitches.length) break;
const stitch = stitches[idx];
@@ -94,13 +89,17 @@ export function calculateLockDirection(
if ((cmd & MOVE) !== 0) continue;
// Accumulate relative coordinates
- const deltaX = Math.round(stitch[0]) - Math.round(stitches[currentIndex][0]);
- const deltaY = Math.round(stitch[1]) - Math.round(stitches[currentIndex][1]);
+ const deltaX =
+ Math.round(stitch[0]) - Math.round(stitches[currentIndex][0]);
+ const deltaY =
+ Math.round(stitch[1]) - Math.round(stitches[currentIndex][1]);
accumulatedX += deltaX;
accumulatedY += deltaY;
- const length = Math.sqrt(accumulatedX * accumulatedX + accumulatedY * accumulatedY);
+ const length = Math.sqrt(
+ accumulatedX * accumulatedX + accumulatedY * accumulatedY,
+ );
// Track the maximum length vector seen so far
if (length > maxLength) {
@@ -113,7 +112,7 @@ export function calculateLockDirection(
if (length >= TARGET_LENGTH) {
return {
dirX: (accumulatedX * 8.0) / length,
- dirY: (accumulatedY * 8.0) / length
+ dirY: (accumulatedY * 8.0) / length,
};
}
}
@@ -122,7 +121,7 @@ export function calculateLockDirection(
if (maxLength > 0.1) {
return {
dirX: (bestX * 8.0) / maxLength,
- dirY: (bestY * 8.0) / maxLength
+ dirY: (bestY * 8.0) / maxLength,
};
}
@@ -140,7 +139,12 @@ export function calculateLockDirection(
* @param dirY Direction Y component (magnitude ~8.0)
* @returns Array of PEN bytes for lock stitches (32 bytes = 8 stitches * 4 bytes)
*/
-export function generateLockStitches(x: number, y: number, dirX: number, dirY: number): number[] {
+export function generateLockStitches(
+ x: number,
+ y: number,
+ dirX: number,
+ dirY: number,
+): number[] {
const lockBytes: number[] = [];
// Generate 8 lock stitches in alternating pattern
@@ -153,7 +157,7 @@ export function generateLockStitches(x: number, y: number, dirX: number, dirY: n
// Generate 8 stitches alternating between forward and backward
for (let i = 0; i < 8; i++) {
// Alternate between forward (+) and backward (-) direction
- const sign = (i % 2 === 0) ? 1 : -1;
+ const sign = i % 2 === 0 ? 1 : -1;
const xAdd = scaledDirX * sign;
const yAdd = scaledDirY * sign;
lockBytes.push(...encodeStitchPosition(x + xAdd, y + yAdd));
@@ -180,7 +184,6 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
// Track position for calculating jump distances
let prevX = 0;
let prevY = 0;
-
for (let i = 0; i < stitches.length; i++) {
const stitch = stitches[i];
@@ -209,26 +212,30 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
// Loop B: End/Cut Vector - Look BACKWARD at previous stitches
// This hides the knot inside the embroidery we just finished
const finishDir = calculateLockDirection(stitches, i - 1, false);
- penStitches.push(...generateLockStitches(prevX, prevY, finishDir.dirX, finishDir.dirY));
+ penStitches.push(
+ ...generateLockStitches(prevX, prevY, finishDir.dirX, finishDir.dirY),
+ );
// Encode jump with both FEED and CUT flags
const xEncoded = (absX << 3) & 0xffff;
let yEncoded = (absY << 3) & 0xffff;
yEncoded |= PEN_FEED_DATA; // Jump flag
- yEncoded |= PEN_CUT_DATA; // Cut flag for long jumps
+ yEncoded |= PEN_CUT_DATA; // Cut flag for long jumps
penStitches.push(
xEncoded & 0xff,
(xEncoded >> 8) & 0xff,
yEncoded & 0xff,
- (yEncoded >> 8) & 0xff
+ (yEncoded >> 8) & 0xff,
);
// Add starting lock stitches at new position
// Loop A: Jump/Entry Vector - Look FORWARD at upcoming stitches
// This hides the knot under the stitches we're about to make
const startDir = calculateLockDirection(stitches, i, true);
- penStitches.push(...generateLockStitches(absX, absY, startDir.dirX, startDir.dirY));
+ penStitches.push(
+ ...generateLockStitches(absX, absY, startDir.dirX, startDir.dirY),
+ );
// Update position and continue
prevX = absX;
@@ -258,7 +265,10 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
// Check for color change by comparing stitch color index
const nextStitch = stitches[i + 1];
const nextStitchColor = nextStitch?.[3];
- const isColorChange = !isLastStitch && nextStitchColor !== undefined && nextStitchColor !== stitchColor;
+ const isColorChange =
+ !isLastStitch &&
+ nextStitchColor !== undefined &&
+ nextStitchColor !== stitchColor;
// Mark the very last stitch of the pattern with DATA_END
if (isLastStitch) {
@@ -270,7 +280,7 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
xEncoded & 0xff,
(xEncoded >> 8) & 0xff,
yEncoded & 0xff,
- (yEncoded >> 8) & 0xff
+ (yEncoded >> 8) & 0xff,
);
// Update position for next iteration
@@ -283,7 +293,9 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
// Calculate direction for starting locks (look forward into the pattern)
const startDir = calculateLockDirection(stitches, i, true);
- penStitches.push(...generateLockStitches(absX, absY, startDir.dirX, startDir.dirY));
+ penStitches.push(
+ ...generateLockStitches(absX, absY, startDir.dirX, startDir.dirY),
+ );
}
// Handle color change: finishing lock, COLOR_END+CUT, jump, starting lock
@@ -297,7 +309,9 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
// Loop C: Color Change Vector - Look FORWARD at the stop event data
// This aligns the knot with the stop command's data block for correct tension
const finishDir = calculateLockDirection(stitches, i, true);
- penStitches.push(...generateLockStitches(absX, absY, finishDir.dirX, finishDir.dirY));
+ penStitches.push(
+ ...generateLockStitches(absX, absY, finishDir.dirX, finishDir.dirY),
+ );
// Step 2: Add COLOR_END + CUT command at CURRENT position (same stitch!)
// This is where the machine pauses and waits for the user to change thread color
@@ -313,7 +327,7 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
colorEndCutXEncoded & 0xff,
(colorEndCutXEncoded >> 8) & 0xff,
colorEndCutYEncoded & 0xff,
- (colorEndCutYEncoded >> 8) & 0xff
+ (colorEndCutYEncoded >> 8) & 0xff,
);
// Machine pauses here for color change
@@ -339,7 +353,7 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
jumpXEncoded & 0xff,
(jumpXEncoded >> 8) & 0xff,
jumpYEncoded & 0xff,
- (jumpYEncoded >> 8) & 0xff
+ (jumpYEncoded >> 8) & 0xff,
);
}
@@ -347,8 +361,14 @@ export function encodeStitchesToPen(stitches: number[][]): PenEncodingResult {
// Loop A: Jump/Entry Vector - Look FORWARD at upcoming stitches in new color
// This hides the knot under the stitches we're about to make
const nextStitchIdx = nextIsJump ? i + 2 : i + 1;
- const startDir = calculateLockDirection(stitches, nextStitchIdx < stitches.length ? nextStitchIdx : i, true);
- penStitches.push(...generateLockStitches(jumpToX, jumpToY, startDir.dirX, startDir.dirY));
+ const startDir = calculateLockDirection(
+ stitches,
+ nextStitchIdx < stitches.length ? nextStitchIdx : i,
+ true,
+ );
+ penStitches.push(
+ ...generateLockStitches(jumpToX, jumpToY, startDir.dirX, startDir.dirY),
+ );
// Update position
prevX = jumpToX;
diff --git a/src/formats/pen/types.ts b/src/formats/pen/types.ts
index e27f7b9..7305a61 100644
--- a/src/formats/pen/types.ts
+++ b/src/formats/pen/types.ts
@@ -9,18 +9,18 @@
* A single decoded PEN stitch with coordinates and flags
*/
export interface DecodedPenStitch {
- x: number; // X coordinate (already shifted right by 3)
- y: number; // Y coordinate (already shifted right by 3)
- xFlags: number; // Flags from X coordinate low 3 bits
- yFlags: number; // Flags from Y coordinate low 3 bits
- isFeed: boolean; // Jump/move without stitching (Y-bit 0)
- isCut: boolean; // Trim/cut thread (Y-bit 1)
+ x: number; // X coordinate (already shifted right by 3)
+ y: number; // Y coordinate (already shifted right by 3)
+ xFlags: number; // Flags from X coordinate low 3 bits
+ yFlags: number; // Flags from Y coordinate low 3 bits
+ isFeed: boolean; // Jump/move without stitching (Y-bit 0)
+ isCut: boolean; // Trim/cut thread (Y-bit 1)
isColorEnd: boolean; // Color change marker (X-bits 0-2 = 0x03)
- isDataEnd: boolean; // Pattern end marker (X-bits 0-2 = 0x05)
+ isDataEnd: boolean; // Pattern end marker (X-bits 0-2 = 0x05)
// Compatibility aliases
- isJump: boolean; // Alias for isFeed (backward compatibility)
- flags: number; // Combined flags (backward compatibility)
+ isJump: boolean; // Alias for isFeed (backward compatibility)
+ flags: number; // Combined flags (backward compatibility)
}
/**
@@ -28,12 +28,12 @@ export interface DecodedPenStitch {
*/
export interface PenColorBlock {
startStitchIndex: number; // Index of first stitch in this color
- endStitchIndex: number; // Index of last stitch in this color
- colorIndex: number; // Color number (0-based)
+ endStitchIndex: number; // Index of last stitch in this color
+ colorIndex: number; // Color number (0-based)
// Compatibility aliases
startStitch: number; // Alias for startStitchIndex (backward compatibility)
- endStitch: number; // Alias for endStitchIndex (backward compatibility)
+ endStitch: number; // Alias for endStitchIndex (backward compatibility)
}
/**
diff --git a/src/main.tsx b/src/main.tsx
index bef5202..eff7ccc 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,10 +1,10 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import './index.css'
-import App from './App.tsx'
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "./index.css";
+import App from "./App.tsx";
-createRoot(document.getElementById('root')!).render(
+createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
-)
+);
diff --git a/src/platform/browser/BrowserFileService.ts b/src/platform/browser/BrowserFileService.ts
index 9d7cd5c..9a2ed5e 100644
--- a/src/platform/browser/BrowserFileService.ts
+++ b/src/platform/browser/BrowserFileService.ts
@@ -1,4 +1,4 @@
-import type { IFileService } from '../interfaces/IFileService';
+import type { IFileService } from "../interfaces/IFileService";
/**
* Browser implementation of file service using HTML input elements
@@ -6,8 +6,8 @@ import type { IFileService } from '../interfaces/IFileService';
export class BrowserFileService implements IFileService {
async openFileDialog(options: { accept: string }): Promise<File | null> {
return new Promise((resolve) => {
- const input = document.createElement('input');
- input.type = 'file';
+ const input = document.createElement("input");
+ input.type = "file";
input.accept = options.accept;
input.onchange = (e) => {
@@ -25,7 +25,7 @@ export class BrowserFileService implements IFileService {
async saveFileDialog(): Promise<void> {
// No-op in browser - could implement download if needed in the future
- console.warn('saveFileDialog not implemented in browser');
+ console.warn("saveFileDialog not implemented in browser");
}
hasNativeDialogs(): boolean {
diff --git a/src/platform/browser/BrowserStorageService.ts b/src/platform/browser/BrowserStorageService.ts
index d79e539..6d73ffb 100644
--- a/src/platform/browser/BrowserStorageService.ts
+++ b/src/platform/browser/BrowserStorageService.ts
@@ -1,6 +1,9 @@
-import { PatternCacheService } from '../../services/PatternCacheService';
-import type { IStorageService, ICachedPattern } from '../interfaces/IStorageService';
-import type { PesPatternData } from '../../formats/import/pesImporter';
+import { PatternCacheService } from "../../services/PatternCacheService";
+import type {
+ IStorageService,
+ ICachedPattern,
+} from "../interfaces/IStorageService";
+import type { PesPatternData } from "../../formats/import/pesImporter";
/**
* Browser implementation of storage service using localStorage
@@ -11,7 +14,7 @@ export class BrowserStorageService implements IStorageService {
uuid: string,
pesData: PesPatternData,
fileName: string,
- patternOffset?: { x: number; y: number }
+ patternOffset?: { x: number; y: number },
): Promise<void> {
PatternCacheService.savePattern(uuid, pesData, fileName, patternOffset);
}
@@ -36,7 +39,12 @@ export class BrowserStorageService implements IStorageService {
PatternCacheService.clearCache();
}
- async getCacheInfo(): Promise<{ hasCache: boolean; fileName?: string; uuid?: string; age?: number }> {
+ async getCacheInfo(): Promise<{
+ hasCache: boolean;
+ fileName?: string;
+ uuid?: string;
+ age?: number;
+ }> {
return PatternCacheService.getCacheInfo();
}
}
diff --git a/src/platform/electron/ElectronFileService.ts b/src/platform/electron/ElectronFileService.ts
index cd86235..393f944 100644
--- a/src/platform/electron/ElectronFileService.ts
+++ b/src/platform/electron/ElectronFileService.ts
@@ -1,4 +1,4 @@
-import type { IFileService } from '../interfaces/IFileService';
+import type { IFileService } from "../interfaces/IFileService";
/**
* Electron implementation of file service using native dialogs via IPC
@@ -6,14 +6,17 @@ import type { IFileService } from '../interfaces/IFileService';
export class ElectronFileService implements IFileService {
async openFileDialog(): Promise<File | null> {
if (!window.electronAPI) {
- throw new Error('Electron API not available');
+ throw new Error("Electron API not available");
}
try {
- const result = await window.electronAPI.invoke<{ filePath: string; fileName: string } | null>('dialog:openFile', {
+ const result = await window.electronAPI.invoke<{
+ filePath: string;
+ fileName: string;
+ } | null>("dialog:openFile", {
filters: [
- { name: 'PES Files', extensions: ['pes'] },
- { name: 'All Files', extensions: ['*'] },
+ { name: "PES Files", extensions: ["pes"] },
+ { name: "All Files", extensions: ["*"] },
],
});
@@ -22,34 +25,46 @@ export class ElectronFileService implements IFileService {
}
// Read the file content
- const buffer = await window.electronAPI.invoke<ArrayBuffer>('fs:readFile', result.filePath);
+ const buffer = await window.electronAPI.invoke<ArrayBuffer>(
+ "fs:readFile",
+ result.filePath,
+ );
const blob = new Blob([buffer]);
- return new File([blob], result.fileName, { type: 'application/octet-stream' });
+ return new File([blob], result.fileName, {
+ type: "application/octet-stream",
+ });
} catch (err) {
- console.error('[ElectronFileService] Failed to open file:', err);
+ console.error("[ElectronFileService] Failed to open file:", err);
return null;
}
}
async saveFileDialog(data: Uint8Array, defaultName: string): Promise<void> {
if (!window.electronAPI) {
- throw new Error('Electron API not available');
+ throw new Error("Electron API not available");
}
try {
- const filePath = await window.electronAPI.invoke<string | null>('dialog:saveFile', {
- defaultPath: defaultName,
- filters: [
- { name: 'PEN Files', extensions: ['pen'] },
- { name: 'All Files', extensions: ['*'] },
- ],
- });
+ const filePath = await window.electronAPI.invoke<string | null>(
+ "dialog:saveFile",
+ {
+ defaultPath: defaultName,
+ filters: [
+ { name: "PEN Files", extensions: ["pen"] },
+ { name: "All Files", extensions: ["*"] },
+ ],
+ },
+ );
if (filePath) {
- await window.electronAPI.invoke('fs:writeFile', filePath, Array.from(data));
+ await window.electronAPI.invoke(
+ "fs:writeFile",
+ filePath,
+ Array.from(data),
+ );
}
} catch (err) {
- console.error('[ElectronFileService] Failed to save file:', err);
+ console.error("[ElectronFileService] Failed to save file:", err);
throw err;
}
}
diff --git a/src/platform/electron/ElectronStorageService.ts b/src/platform/electron/ElectronStorageService.ts
index 629c4a8..acf7fb2 100644
--- a/src/platform/electron/ElectronStorageService.ts
+++ b/src/platform/electron/ElectronStorageService.ts
@@ -1,5 +1,8 @@
-import type { IStorageService, ICachedPattern } from '../interfaces/IStorageService';
-import type { PesPatternData } from '../../formats/import/pesImporter';
+import type {
+ IStorageService,
+ ICachedPattern,
+} from "../interfaces/IStorageService";
+import type { PesPatternData } from "../../formats/import/pesImporter";
/**
* Electron implementation of storage service using electron-store via IPC
@@ -7,7 +10,7 @@ import type { PesPatternData } from '../../formats/import/pesImporter';
export class ElectronStorageService implements IStorageService {
private async invoke<T>(channel: string, ...args: unknown[]): Promise<T> {
if (!window.electronAPI) {
- throw new Error('Electron API not available');
+ throw new Error("Electron API not available");
}
return window.electronAPI.invoke(channel, ...args);
}
@@ -16,7 +19,7 @@ export class ElectronStorageService implements IStorageService {
uuid: string,
pesData: PesPatternData,
fileName: string,
- patternOffset?: { x: number; y: number }
+ patternOffset?: { x: number; y: number },
): Promise<void> {
// Convert Uint8Array to array for JSON serialization over IPC
const serializable = {
@@ -31,14 +34,17 @@ export class ElectronStorageService implements IStorageService {
};
// Fire and forget (sync-like behavior to match interface)
- this.invoke('storage:savePattern', serializable).catch(err => {
- console.error('[ElectronStorage] Failed to save pattern:', err);
+ this.invoke("storage:savePattern", serializable).catch((err) => {
+ console.error("[ElectronStorage] Failed to save pattern:", err);
});
}
async getPatternByUUID(uuid: string): Promise<ICachedPattern | null> {
try {
- const pattern = await this.invoke<ICachedPattern | null>('storage:getPattern', uuid);
+ const pattern = await this.invoke<ICachedPattern | null>(
+ "storage:getPattern",
+ uuid,
+ );
if (pattern && Array.isArray(pattern.pesData.penData)) {
// Restore Uint8Array from array
@@ -47,14 +53,16 @@ export class ElectronStorageService implements IStorageService {
return pattern;
} catch (err) {
- console.error('[ElectronStorage] Failed to get pattern:', err);
+ console.error("[ElectronStorage] Failed to get pattern:", err);
return null;
}
}
async getMostRecentPattern(): Promise<ICachedPattern | null> {
try {
- const pattern = await this.invoke<ICachedPattern | null>('storage:getLatest');
+ const pattern = await this.invoke<ICachedPattern | null>(
+ "storage:getLatest",
+ );
if (pattern && Array.isArray(pattern.pesData.penData)) {
// Restore Uint8Array from array
@@ -63,7 +71,7 @@ export class ElectronStorageService implements IStorageService {
return pattern;
} catch (err) {
- console.error('[ElectronStorage] Failed to get latest pattern:', err);
+ console.error("[ElectronStorage] Failed to get latest pattern:", err);
return null;
}
}
@@ -71,29 +79,38 @@ export class ElectronStorageService implements IStorageService {
async hasPattern(): Promise<boolean> {
// Since this is async in Electron, we can't truly implement this synchronously
// Returning false as a safe default
- console.warn('[ElectronStorage] hasPattern called synchronously, returning false');
+ console.warn(
+ "[ElectronStorage] hasPattern called synchronously, returning false",
+ );
return false;
}
async deletePattern(uuid: string): Promise<void> {
try {
- await this.invoke('storage:deletePattern', uuid);
+ await this.invoke("storage:deletePattern", uuid);
} catch (err) {
- console.error('[ElectronStorage] Failed to delete pattern:', err);
+ console.error("[ElectronStorage] Failed to delete pattern:", err);
}
}
async clearCache(): Promise<void> {
try {
- await this.invoke('storage:clear');
+ await this.invoke("storage:clear");
} catch (err) {
- console.error('[ElectronStorage] Failed to clear cache:', err);
+ console.error("[ElectronStorage] Failed to clear cache:", err);
}
}
- async getCacheInfo(): Promise<{ hasCache: boolean; fileName?: string; uuid?: string; age?: number }> {
+ async getCacheInfo(): Promise<{
+ hasCache: boolean;
+ fileName?: string;
+ uuid?: string;
+ age?: number;
+ }> {
// This needs to be async in Electron, return empty info synchronously
- console.warn('[ElectronStorage] getCacheInfo called synchronously, returning empty');
+ console.warn(
+ "[ElectronStorage] getCacheInfo called synchronously, returning empty",
+ );
return { hasCache: false };
}
}
diff --git a/src/platform/index.ts b/src/platform/index.ts
index c433ddf..04630d5 100644
--- a/src/platform/index.ts
+++ b/src/platform/index.ts
@@ -1,15 +1,19 @@
-import type { IStorageService } from './interfaces/IStorageService';
-import type { IFileService } from './interfaces/IFileService';
-import { BrowserStorageService } from './browser/BrowserStorageService';
-import { BrowserFileService } from './browser/BrowserFileService';
-import { ElectronStorageService } from './electron/ElectronStorageService';
-import { ElectronFileService } from './electron/ElectronFileService';
+import type { IStorageService } from "./interfaces/IStorageService";
+import type { IFileService } from "./interfaces/IFileService";
+import { BrowserStorageService } from "./browser/BrowserStorageService";
+import { BrowserFileService } from "./browser/BrowserFileService";
+import { ElectronStorageService } from "./electron/ElectronStorageService";
+import { ElectronFileService } from "./electron/ElectronFileService";
/**
* Detect if running in Electron
*/
export function isElectron(): boolean {
- return !!(typeof window !== 'undefined' && window.process && window.process.type === 'renderer');
+ return !!(
+ typeof window !== "undefined" &&
+ window.process &&
+ window.process.type === "renderer"
+ );
}
/**
diff --git a/src/platform/interfaces/IStorageService.ts b/src/platform/interfaces/IStorageService.ts
index 75c7aa6..6154e77 100644
--- a/src/platform/interfaces/IStorageService.ts
+++ b/src/platform/interfaces/IStorageService.ts
@@ -1,4 +1,4 @@
-import type { PesPatternData } from '../../formats/import/pesImporter';
+import type { PesPatternData } from "../../formats/import/pesImporter";
export interface ICachedPattern {
uuid: string;
@@ -13,7 +13,7 @@ export interface IStorageService {
uuid: string,
pesData: PesPatternData,
fileName: string,
- patternOffset?: { x: number; y: number }
+ patternOffset?: { x: number; y: number },
): Promise<void>;
getPatternByUUID(uuid: string): Promise<ICachedPattern | null>;
@@ -21,5 +21,10 @@ export interface IStorageService {
hasPattern(uuid: string): Promise<boolean>;
deletePattern(uuid: string): Promise<void>;
clearCache(): Promise<void>;
- getCacheInfo(): Promise<{ hasCache: boolean; fileName?: string; uuid?: string; age?: number }>;
+ getCacheInfo(): Promise<{
+ hasCache: boolean;
+ fileName?: string;
+ uuid?: string;
+ age?: number;
+ }>;
}
diff --git a/src/services/BrotherPP1Service.ts b/src/services/BrotherPP1Service.ts
index f633066..2b75618 100644
--- a/src/services/BrotherPP1Service.ts
+++ b/src/services/BrotherPP1Service.ts
@@ -9,7 +9,7 @@ import { MachineStatus } from "../types/machine";
export class BluetoothPairingError extends Error {
constructor(message: string) {
super(message);
- this.name = 'BluetoothPairingError';
+ this.name = "BluetoothPairingError";
}
}
@@ -57,7 +57,8 @@ export class BrotherPP1Service {
private isProcessingQueue = false;
private isCommunicating = false;
private isInitialConnection = false;
- private communicationCallbacks: Set<(isCommunicating: boolean) => void> = new Set();
+ private communicationCallbacks: Set<(isCommunicating: boolean) => void> =
+ new Set();
private disconnectCallbacks: Set<() => void> = new Set();
/**
@@ -65,7 +66,9 @@ export class BrotherPP1Service {
* @param callback Function called when communication state changes
* @returns Unsubscribe function
*/
- onCommunicationChange(callback: (isCommunicating: boolean) => void): () => void {
+ onCommunicationChange(
+ callback: (isCommunicating: boolean) => void,
+ ): () => void {
this.communicationCallbacks.add(callback);
// Immediately call with current state
callback(this.isCommunicating);
@@ -89,19 +92,19 @@ export class BrotherPP1Service {
private setCommunicating(value: boolean) {
if (this.isCommunicating !== value) {
this.isCommunicating = value;
- this.communicationCallbacks.forEach(callback => callback(value));
+ this.communicationCallbacks.forEach((callback) => callback(value));
}
}
private handleDisconnect() {
- console.log('[BrotherPP1Service] Device disconnected');
+ console.log("[BrotherPP1Service] Device disconnected");
this.server = null;
this.writeCharacteristic = null;
this.readCharacteristic = null;
this.commandQueue = [];
this.isProcessingQueue = false;
this.setCommunicating(false);
- this.disconnectCallbacks.forEach(callback => callback());
+ this.disconnectCallbacks.forEach((callback) => callback());
}
async connect(): Promise<void> {
@@ -116,7 +119,7 @@ export class BrotherPP1Service {
}
// Listen for disconnection events
- this.device.addEventListener('gattserverdisconnected', () => {
+ this.device.addEventListener("gattserverdisconnected", () => {
this.handleDisconnect();
});
@@ -126,7 +129,8 @@ export class BrotherPP1Service {
const service = await this.server.getPrimaryService(SERVICE_UUID);
console.log("Got primary service");
- this.writeCharacteristic = await service.getCharacteristic(WRITE_CHAR_UUID);
+ this.writeCharacteristic =
+ await service.getCharacteristic(WRITE_CHAR_UUID);
this.readCharacteristic = await service.getCharacteristic(READ_CHAR_UUID);
console.log("Connected to Brother PP1 machine");
@@ -136,7 +140,9 @@ export class BrotherPP1Service {
console.log("Validating connection with test command...");
try {
await this.getMachineState();
- console.log("Connection validation successful - device is properly paired");
+ console.log(
+ "Connection validation successful - device is properly paired",
+ );
} catch (e) {
console.log("Connection validation failed:", e);
// Disconnect to clean up
@@ -289,16 +295,21 @@ export class BrotherPP1Service {
// Detect pairing issues during initial connection - empty or invalid response
if (this.isInitialConnection) {
if (response.length === 0) {
- console.log('[BrotherPP1] Empty response received - device likely not paired');
+ console.log(
+ "[BrotherPP1] Empty response received - device likely not paired",
+ );
throw new BluetoothPairingError(
- 'Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system\'s Bluetooth settings. After pairing, try connecting again.'
+ "Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system's Bluetooth settings. After pairing, try connecting again.",
);
}
// Check for invalid response (less than 3 bytes means no proper command response)
if (response.length < 3) {
- console.log('[BrotherPP1] Invalid response length:', response.length);
+ console.log(
+ "[BrotherPP1] Invalid response length:",
+ response.length,
+ );
throw new BluetoothPairingError(
- 'Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system\'s Bluetooth settings. After pairing, try connecting again.'
+ "Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system's Bluetooth settings. After pairing, try connecting again.",
);
}
}
@@ -322,11 +333,12 @@ export class BrotherPP1Service {
if (this.isInitialConnection && error instanceof Error) {
const errorMsg = error.message.toLowerCase();
if (
- errorMsg.includes('gatt server is disconnected') ||
- (errorMsg.includes('writevaluewithresponse') && errorMsg.includes('gatt server is disconnected'))
+ errorMsg.includes("gatt server is disconnected") ||
+ (errorMsg.includes("writevaluewithresponse") &&
+ errorMsg.includes("gatt server is disconnected"))
) {
throw new BluetoothPairingError(
- 'Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system\'s Bluetooth settings. After pairing, try connecting again.'
+ "Device not paired. To pair: long-press the Bluetooth button on the machine, then pair it using your operating system's Bluetooth settings. After pairing, try connecting again.",
);
}
}
@@ -369,7 +381,7 @@ export class BrotherPP1Service {
serviceCount = serviceData.serviceCount;
totalCount = serviceData.totalCount;
} catch (err) {
- console.warn('[BrotherPP1] Failed to fetch service count:', err);
+ console.warn("[BrotherPP1] Failed to fetch service count:", err);
}
return {
@@ -385,17 +397,23 @@ export class BrotherPP1Service {
};
}
- async getServiceCount(): Promise<{ serviceCount: number; totalCount: number }> {
+ async getServiceCount(): Promise<{
+ serviceCount: number;
+ totalCount: number;
+ }> {
const response = await this.sendCommand(Commands.SERVICE_COUNT);
const data = response.slice(2);
// Read uint32 values in little-endian format
const readUInt32LE = (offset: number) =>
- data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24);
+ data[offset] |
+ (data[offset + 1] << 8) |
+ (data[offset + 2] << 16) |
+ (data[offset + 3] << 24);
return {
serviceCount: readUInt32LE(0), // Bytes 0-3
- totalCount: readUInt32LE(4), // Bytes 4-7
+ totalCount: readUInt32LE(4), // Bytes 4-7
};
}
@@ -427,8 +445,10 @@ export class BrotherPP1Service {
speed: readUInt16LE(12),
};
- console.log('[BrotherPP1] Pattern Info Response:', {
- rawData: Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(' '),
+ console.log("[BrotherPP1] Pattern Info Response:", {
+ rawData: Array.from(data)
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join(" "),
parsed: patternInfo,
});
@@ -580,7 +600,7 @@ export class BrotherPP1Service {
payload[24] = flip;
payload[25] = frame;
- console.log('[DEBUG] Layout bounds:', {
+ console.log("[DEBUG] Layout bounds:", {
boundLeft,
boundTop,
boundRight,
@@ -675,7 +695,7 @@ export class BrotherPP1Service {
moveX = patternOffset.x - patternCenterX;
moveY = patternOffset.y - patternCenterY;
- console.log('[LAYOUT] Using user-defined offset:', {
+ console.log("[LAYOUT] Using user-defined offset:", {
patternOffset,
patternCenter: { x: patternCenterX, y: patternCenterY },
moveX,
@@ -688,7 +708,7 @@ export class BrotherPP1Service {
moveX = -patternCenterX;
moveY = -patternCenterY;
- console.log('[LAYOUT] Auto-centering pattern:', { moveX, moveY });
+ console.log("[LAYOUT] Auto-centering pattern:", { moveX, moveY });
}
// Send layout with actual pattern bounds
diff --git a/src/services/PatternCacheService.ts b/src/services/PatternCacheService.ts
index 3f5d64d..2c11fe5 100644
--- a/src/services/PatternCacheService.ts
+++ b/src/services/PatternCacheService.ts
@@ -1,173 +1,197 @@
-import type { PesPatternData } from '../formats/import/pesImporter';
-
-interface CachedPattern {
- uuid: string;
- pesData: PesPatternData;
- fileName: string;
- timestamp: number;
- patternOffset?: { x: number; y: number };
-}
-
-const CACHE_KEY = 'brother_pattern_cache';
-
-/**
- * Convert UUID Uint8Array to hex string
- */
-export function uuidToString(uuid: Uint8Array): string {
- return Array.from(uuid).map(b => b.toString(16).padStart(2, '0')).join('');
-}
-
-/**
- * Convert hex string to UUID Uint8Array
- */
-export function stringToUuid(str: string): Uint8Array {
- const bytes = new Uint8Array(16);
- for (let i = 0; i < 16; i++) {
- bytes[i] = parseInt(str.substr(i * 2, 2), 16);
- }
- return bytes;
-}
-
-export class PatternCacheService {
- /**
- * Save pattern to local storage with its UUID
- */
- static savePattern(
- uuid: string,
- pesData: PesPatternData,
- fileName: string,
- patternOffset?: { x: number; y: number }
- ): void {
- try {
- // Convert penData Uint8Array to array for JSON serialization
- const pesDataWithArrayPenData = {
- ...pesData,
- penData: Array.from(pesData.penData) as unknown as Uint8Array,
- };
-
- const cached: CachedPattern = {
- uuid,
- pesData: pesDataWithArrayPenData,
- fileName,
- timestamp: Date.now(),
- patternOffset,
- };
-
- localStorage.setItem(CACHE_KEY, JSON.stringify(cached));
- console.log('[PatternCache] Saved pattern:', fileName, 'UUID:', uuid, 'Offset:', patternOffset);
- } catch (err) {
- console.error('[PatternCache] Failed to save pattern:', err);
- // If quota exceeded, clear and try again
- if (err instanceof Error && err.name === 'QuotaExceededError') {
- this.clearCache();
- }
- }
- }
-
- /**
- * Get cached pattern by UUID
- */
- static getPatternByUUID(uuid: string): CachedPattern | null {
- try {
- const cached = localStorage.getItem(CACHE_KEY);
- if (!cached) {
- return null;
- }
-
- const pattern: CachedPattern = JSON.parse(cached);
-
- // Check if UUID matches
- if (pattern.uuid !== uuid) {
- console.log('[PatternCache] UUID mismatch. Cached:', pattern.uuid, 'Requested:', uuid);
- return null;
- }
-
- // Restore Uint8Array from array inside pesData
- if (Array.isArray(pattern.pesData.penData)) {
- pattern.pesData.penData = new Uint8Array(pattern.pesData.penData);
- }
-
- console.log('[PatternCache] Found cached pattern:', pattern.fileName, 'UUID:', uuid);
- return pattern;
- } catch (err) {
- console.error('[PatternCache] Failed to retrieve pattern:', err);
- return null;
- }
- }
-
- /**
- * Get the most recent cached pattern (regardless of UUID)
- */
- static getMostRecentPattern(): CachedPattern | null {
- try {
- const cached = localStorage.getItem(CACHE_KEY);
- if (!cached) {
- return null;
- }
-
- const pattern: CachedPattern = JSON.parse(cached);
-
- // Restore Uint8Array from array inside pesData
- if (Array.isArray(pattern.pesData.penData)) {
- pattern.pesData.penData = new Uint8Array(pattern.pesData.penData);
- }
-
- return pattern;
- } catch (err) {
- console.error('[PatternCache] Failed to retrieve pattern:', err);
- return null;
- }
- }
-
- /**
- * Check if a pattern with the given UUID exists in cache
- */
- static hasPattern(uuid: string): boolean {
- const pattern = this.getMostRecentPattern();
- return pattern?.uuid === uuid;
- }
-
- /**
- * Delete a specific pattern by UUID
- */
- static deletePattern(uuid: string): void {
- try {
- const cached = this.getPatternByUUID(uuid);
- if (cached) {
- localStorage.removeItem(CACHE_KEY);
- console.log('[PatternCache] Deleted pattern with UUID:', uuid);
- }
- } catch (err) {
- console.error('[PatternCache] Failed to delete pattern:', err);
- }
- }
-
- /**
- * Clear the pattern cache
- */
- static clearCache(): void {
- try {
- localStorage.removeItem(CACHE_KEY);
- console.log('[PatternCache] Cache cleared');
- } catch (err) {
- console.error('[PatternCache] Failed to clear cache:', err);
- }
- }
-
- /**
- * Get cache info for debugging
- */
- static getCacheInfo(): { hasCache: boolean; fileName?: string; uuid?: string; age?: number } {
- const pattern = this.getMostRecentPattern();
- if (!pattern) {
- return { hasCache: false };
- }
-
- return {
- hasCache: true,
- fileName: pattern.fileName,
- uuid: pattern.uuid,
- age: Date.now() - pattern.timestamp,
- };
- }
-}
+import type { PesPatternData } from "../formats/import/pesImporter";
+
+interface CachedPattern {
+ uuid: string;
+ pesData: PesPatternData;
+ fileName: string;
+ timestamp: number;
+ patternOffset?: { x: number; y: number };
+}
+
+const CACHE_KEY = "brother_pattern_cache";
+
+/**
+ * Convert UUID Uint8Array to hex string
+ */
+export function uuidToString(uuid: Uint8Array): string {
+ return Array.from(uuid)
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+}
+
+/**
+ * Convert hex string to UUID Uint8Array
+ */
+export function stringToUuid(str: string): Uint8Array {
+ const bytes = new Uint8Array(16);
+ for (let i = 0; i < 16; i++) {
+ bytes[i] = parseInt(str.substr(i * 2, 2), 16);
+ }
+ return bytes;
+}
+
+export class PatternCacheService {
+ /**
+ * Save pattern to local storage with its UUID
+ */
+ static savePattern(
+ uuid: string,
+ pesData: PesPatternData,
+ fileName: string,
+ patternOffset?: { x: number; y: number },
+ ): void {
+ try {
+ // Convert penData Uint8Array to array for JSON serialization
+ const pesDataWithArrayPenData = {
+ ...pesData,
+ penData: Array.from(pesData.penData) as unknown as Uint8Array,
+ };
+
+ const cached: CachedPattern = {
+ uuid,
+ pesData: pesDataWithArrayPenData,
+ fileName,
+ timestamp: Date.now(),
+ patternOffset,
+ };
+
+ localStorage.setItem(CACHE_KEY, JSON.stringify(cached));
+ console.log(
+ "[PatternCache] Saved pattern:",
+ fileName,
+ "UUID:",
+ uuid,
+ "Offset:",
+ patternOffset,
+ );
+ } catch (err) {
+ console.error("[PatternCache] Failed to save pattern:", err);
+ // If quota exceeded, clear and try again
+ if (err instanceof Error && err.name === "QuotaExceededError") {
+ this.clearCache();
+ }
+ }
+ }
+
+ /**
+ * Get cached pattern by UUID
+ */
+ static getPatternByUUID(uuid: string): CachedPattern | null {
+ try {
+ const cached = localStorage.getItem(CACHE_KEY);
+ if (!cached) {
+ return null;
+ }
+
+ const pattern: CachedPattern = JSON.parse(cached);
+
+ // Check if UUID matches
+ if (pattern.uuid !== uuid) {
+ console.log(
+ "[PatternCache] UUID mismatch. Cached:",
+ pattern.uuid,
+ "Requested:",
+ uuid,
+ );
+ return null;
+ }
+
+ // Restore Uint8Array from array inside pesData
+ if (Array.isArray(pattern.pesData.penData)) {
+ pattern.pesData.penData = new Uint8Array(pattern.pesData.penData);
+ }
+
+ console.log(
+ "[PatternCache] Found cached pattern:",
+ pattern.fileName,
+ "UUID:",
+ uuid,
+ );
+ return pattern;
+ } catch (err) {
+ console.error("[PatternCache] Failed to retrieve pattern:", err);
+ return null;
+ }
+ }
+
+ /**
+ * Get the most recent cached pattern (regardless of UUID)
+ */
+ static getMostRecentPattern(): CachedPattern | null {
+ try {
+ const cached = localStorage.getItem(CACHE_KEY);
+ if (!cached) {
+ return null;
+ }
+
+ const pattern: CachedPattern = JSON.parse(cached);
+
+ // Restore Uint8Array from array inside pesData
+ if (Array.isArray(pattern.pesData.penData)) {
+ pattern.pesData.penData = new Uint8Array(pattern.pesData.penData);
+ }
+
+ return pattern;
+ } catch (err) {
+ console.error("[PatternCache] Failed to retrieve pattern:", err);
+ return null;
+ }
+ }
+
+ /**
+ * Check if a pattern with the given UUID exists in cache
+ */
+ static hasPattern(uuid: string): boolean {
+ const pattern = this.getMostRecentPattern();
+ return pattern?.uuid === uuid;
+ }
+
+ /**
+ * Delete a specific pattern by UUID
+ */
+ static deletePattern(uuid: string): void {
+ try {
+ const cached = this.getPatternByUUID(uuid);
+ if (cached) {
+ localStorage.removeItem(CACHE_KEY);
+ console.log("[PatternCache] Deleted pattern with UUID:", uuid);
+ }
+ } catch (err) {
+ console.error("[PatternCache] Failed to delete pattern:", err);
+ }
+ }
+
+ /**
+ * Clear the pattern cache
+ */
+ static clearCache(): void {
+ try {
+ localStorage.removeItem(CACHE_KEY);
+ console.log("[PatternCache] Cache cleared");
+ } catch (err) {
+ console.error("[PatternCache] Failed to clear cache:", err);
+ }
+ }
+
+ /**
+ * Get cache info for debugging
+ */
+ static getCacheInfo(): {
+ hasCache: boolean;
+ fileName?: string;
+ uuid?: string;
+ age?: number;
+ } {
+ const pattern = this.getMostRecentPattern();
+ if (!pattern) {
+ return { hasCache: false };
+ }
+
+ return {
+ hasCache: true,
+ fileName: pattern.fileName,
+ uuid: pattern.uuid,
+ age: Date.now() - pattern.timestamp,
+ };
+ }
+}
diff --git a/src/stores/usePatternStore.ts b/src/stores/usePatternStore.ts
index 21ae53e..1c20dc2 100644
--- a/src/stores/usePatternStore.ts
+++ b/src/stores/usePatternStore.ts
@@ -1,5 +1,5 @@
-import { create } from 'zustand';
-import type { PesPatternData } from '../formats/import/pesImporter';
+import { create } from "zustand";
+import type { PesPatternData } from "../formats/import/pesImporter";
interface PatternState {
// Pattern data
@@ -19,7 +19,7 @@ interface PatternState {
export const usePatternStore = create<PatternState>((set) => ({
// Initial state
pesData: null,
- currentFileName: '',
+ currentFileName: "",
patternOffset: { x: 0, y: 0 },
patternUploaded: false,
@@ -36,7 +36,7 @@ export const usePatternStore = create<PatternState>((set) => ({
// Update pattern offset
setPatternOffset: (x: number, y: number) => {
set({ patternOffset: { x, y } });
- console.log('[PatternStore] Pattern offset changed:', { x, y });
+ console.log("[PatternStore] Pattern offset changed:", { x, y });
},
// Mark pattern as uploaded/not uploaded
@@ -61,6 +61,9 @@ export const usePatternStore = create<PatternState>((set) => ({
// Selector hooks for common use cases
export const usePesData = () => usePatternStore((state) => state.pesData);
-export const usePatternFileName = () => usePatternStore((state) => state.currentFileName);
-export const usePatternOffset = () => usePatternStore((state) => state.patternOffset);
-export const usePatternUploaded = () => usePatternStore((state) => state.patternUploaded);
+export const usePatternFileName = () =>
+ usePatternStore((state) => state.currentFileName);
+export const usePatternOffset = () =>
+ usePatternStore((state) => state.patternOffset);
+export const usePatternUploaded = () =>
+ usePatternStore((state) => state.patternUploaded);
diff --git a/src/stores/useUIStore.ts b/src/stores/useUIStore.ts
index 42b7cb9..8ce905f 100644
--- a/src/stores/useUIStore.ts
+++ b/src/stores/useUIStore.ts
@@ -1,5 +1,5 @@
-import { create } from 'zustand';
-import { patternConverterClient } from '../formats/import/client';
+import { create } from "zustand";
+import { patternConverterClient } from "../formats/import/client";
interface UIState {
// Pyodide state
@@ -23,26 +23,41 @@ export const useUIStore = create<UIState>((set) => ({
pyodideReady: false,
pyodideError: null,
pyodideProgress: 0,
- pyodideLoadingStep: '',
+ pyodideLoadingStep: "",
showErrorPopover: false,
// Initialize Pyodide with progress tracking
initializePyodide: async () => {
try {
// Reset progress
- set({ pyodideProgress: 0, pyodideLoadingStep: 'Starting...', pyodideError: null });
+ set({
+ pyodideProgress: 0,
+ pyodideLoadingStep: "Starting...",
+ pyodideError: null,
+ });
// Initialize with progress callback
await patternConverterClient.initialize((progress, step) => {
set({ pyodideProgress: progress, pyodideLoadingStep: step });
});
- set({ pyodideReady: true, pyodideProgress: 100, pyodideLoadingStep: 'Ready!' });
- console.log('[UIStore] Pyodide initialized successfully');
+ set({
+ pyodideReady: true,
+ pyodideProgress: 100,
+ pyodideLoadingStep: "Ready!",
+ });
+ console.log("[UIStore] Pyodide initialized successfully");
} catch (err) {
- const errorMessage = err instanceof Error ? err.message : 'Failed to initialize Python environment';
- set({ pyodideError: errorMessage, pyodideProgress: 0, pyodideLoadingStep: '' });
- console.error('[UIStore] Failed to initialize Pyodide:', err);
+ const errorMessage =
+ err instanceof Error
+ ? err.message
+ : "Failed to initialize Python environment";
+ set({
+ pyodideError: errorMessage,
+ pyodideProgress: 0,
+ pyodideLoadingStep: "",
+ });
+ console.error("[UIStore] Failed to initialize Pyodide:", err);
}
},
@@ -65,6 +80,9 @@ export const useUIStore = create<UIState>((set) => ({
// Selector hooks for common use cases
export const usePyodideReady = () => useUIStore((state) => state.pyodideReady);
export const usePyodideError = () => useUIStore((state) => state.pyodideError);
-export const usePyodideProgress = () => useUIStore((state) => state.pyodideProgress);
-export const usePyodideLoadingStep = () => useUIStore((state) => state.pyodideLoadingStep);
-export const useErrorPopover = () => useUIStore((state) => state.showErrorPopover);
+export const usePyodideProgress = () =>
+ useUIStore((state) => state.pyodideProgress);
+export const usePyodideLoadingStep = () =>
+ useUIStore((state) => state.pyodideLoadingStep);
+export const useErrorPopover = () =>
+ useUIStore((state) => state.showErrorPopover);
diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts
index c357fa4..87167ab 100644
--- a/src/types/electron.d.ts
+++ b/src/types/electron.d.ts
@@ -5,7 +5,9 @@ export interface BluetoothDevice {
export interface ElectronAPI {
invoke<T = unknown>(channel: string, ...args: unknown[]): Promise<T>;
- onBluetoothDeviceList: (callback: (devices: BluetoothDevice[]) => void) => void;
+ onBluetoothDeviceList: (
+ callback: (devices: BluetoothDevice[]) => void,
+ ) => void;
selectBluetoothDevice: (deviceId: string) => void;
}
diff --git a/src/types/machine.ts b/src/types/machine.ts
index 824e0c7..883cb40 100644
--- a/src/types/machine.ts
+++ b/src/types/machine.ts
@@ -1,105 +1,105 @@
-// Brother PP1 Machine Types
-
-export const MachineStatus = {
- Initial: 0x00,
- LowerThread: 0x01,
- IDLE: 0x10,
- SEWING_WAIT: 0x11,
- SEWING_DATA_RECEIVE: 0x12,
- MASK_TRACE_LOCK_WAIT: 0x20,
- MASK_TRACING: 0x21,
- MASK_TRACE_COMPLETE: 0x22,
- SEWING: 0x30,
- SEWING_COMPLETE: 0x31,
- SEWING_INTERRUPTION: 0x32,
- COLOR_CHANGE_WAIT: 0x40,
- PAUSE: 0x41,
- STOP: 0x42,
- HOOP_AVOIDANCE: 0x50,
- HOOP_AVOIDANCEING: 0x51,
- RL_RECEIVING: 0x60,
- RL_RECEIVED: 0x61,
- None: 0xDD,
- TryConnecting: 0xFF,
-} as const;
-
-export type MachineStatus = typeof MachineStatus[keyof typeof MachineStatus];
-
-export const MachineStatusNames: Record<MachineStatus, string> = {
- [MachineStatus.Initial]: 'Initial',
- [MachineStatus.LowerThread]: 'Lower Thread',
- [MachineStatus.IDLE]: 'Idle',
- [MachineStatus.SEWING_WAIT]: 'Ready to Sew',
- [MachineStatus.SEWING_DATA_RECEIVE]: 'Receiving Data',
- [MachineStatus.MASK_TRACE_LOCK_WAIT]: 'Waiting for Mask Trace',
- [MachineStatus.MASK_TRACING]: 'Mask Tracing',
- [MachineStatus.MASK_TRACE_COMPLETE]: 'Mask Trace Complete',
- [MachineStatus.SEWING]: 'Sewing',
- [MachineStatus.SEWING_COMPLETE]: 'Complete',
- [MachineStatus.SEWING_INTERRUPTION]: 'Interrupted',
- [MachineStatus.COLOR_CHANGE_WAIT]: 'Waiting for Color Change',
- [MachineStatus.PAUSE]: 'Paused',
- [MachineStatus.STOP]: 'Stopped',
- [MachineStatus.HOOP_AVOIDANCE]: 'Hoop Avoidance',
- [MachineStatus.HOOP_AVOIDANCEING]: 'Hoop Avoidance In Progress',
- [MachineStatus.RL_RECEIVING]: 'RL Receiving',
- [MachineStatus.RL_RECEIVED]: 'RL Received',
- [MachineStatus.None]: 'None',
- [MachineStatus.TryConnecting]: 'Connecting',
-};
-
-export interface MachineInfo {
- serialNumber: string;
- modelNumber: string;
- softwareVersion: string;
- bluetoothVersion: number;
- maxWidth: number; // in 0.1mm units
- maxHeight: number; // in 0.1mm units
- macAddress: string;
- serviceCount?: number; // Cumulative service counter
- totalCount?: number; // Total stitches sewn by machine
-}
-
-export interface PatternInfo {
- totalStitches: number;
- totalTime: number; // seconds
- speed: number; // stitches per minute
- boundLeft: number;
- boundTop: number;
- boundRight: number;
- boundBottom: number;
-}
-
-export interface SewingProgress {
- currentStitch: number;
- currentTime: number; // seconds
- stopTime: number;
- positionX: number; // in 0.1mm units
- positionY: number; // in 0.1mm units
-}
-
-export interface PenStitch {
- x: number;
- y: number;
- flags: number;
- isJump: boolean;
-}
-
-export interface PenColorBlock {
- startStitch: number;
- endStitch: number;
- colorIndex: number;
-}
-
-export interface PenData {
- stitches: PenStitch[];
- colorBlocks: PenColorBlock[];
- totalStitches: number;
- colorCount: number;
- bounds: {
- minX: number;
- maxX: number;
- minY: number;
- maxY: number;
- };
-}
+// Brother PP1 Machine Types
+
+export const MachineStatus = {
+ Initial: 0x00,
+ LowerThread: 0x01,
+ IDLE: 0x10,
+ SEWING_WAIT: 0x11,
+ SEWING_DATA_RECEIVE: 0x12,
+ MASK_TRACE_LOCK_WAIT: 0x20,
+ MASK_TRACING: 0x21,
+ MASK_TRACE_COMPLETE: 0x22,
+ SEWING: 0x30,
+ SEWING_COMPLETE: 0x31,
+ SEWING_INTERRUPTION: 0x32,
+ COLOR_CHANGE_WAIT: 0x40,
+ PAUSE: 0x41,
+ STOP: 0x42,
+ HOOP_AVOIDANCE: 0x50,
+ HOOP_AVOIDANCEING: 0x51,
+ RL_RECEIVING: 0x60,
+ RL_RECEIVED: 0x61,
+ None: 0xdd,
+ TryConnecting: 0xff,
+} as const;
+
+export type MachineStatus = (typeof MachineStatus)[keyof typeof MachineStatus];
+
+export const MachineStatusNames: Record<MachineStatus, string> = {
+ [MachineStatus.Initial]: "Initial",
+ [MachineStatus.LowerThread]: "Lower Thread",
+ [MachineStatus.IDLE]: "Idle",
+ [MachineStatus.SEWING_WAIT]: "Ready to Sew",
+ [MachineStatus.SEWING_DATA_RECEIVE]: "Receiving Data",
+ [MachineStatus.MASK_TRACE_LOCK_WAIT]: "Waiting for Mask Trace",
+ [MachineStatus.MASK_TRACING]: "Mask Tracing",
+ [MachineStatus.MASK_TRACE_COMPLETE]: "Mask Trace Complete",
+ [MachineStatus.SEWING]: "Sewing",
+ [MachineStatus.SEWING_COMPLETE]: "Complete",
+ [MachineStatus.SEWING_INTERRUPTION]: "Interrupted",
+ [MachineStatus.COLOR_CHANGE_WAIT]: "Waiting for Color Change",
+ [MachineStatus.PAUSE]: "Paused",
+ [MachineStatus.STOP]: "Stopped",
+ [MachineStatus.HOOP_AVOIDANCE]: "Hoop Avoidance",
+ [MachineStatus.HOOP_AVOIDANCEING]: "Hoop Avoidance In Progress",
+ [MachineStatus.RL_RECEIVING]: "RL Receiving",
+ [MachineStatus.RL_RECEIVED]: "RL Received",
+ [MachineStatus.None]: "None",
+ [MachineStatus.TryConnecting]: "Connecting",
+};
+
+export interface MachineInfo {
+ serialNumber: string;
+ modelNumber: string;
+ softwareVersion: string;
+ bluetoothVersion: number;
+ maxWidth: number; // in 0.1mm units
+ maxHeight: number; // in 0.1mm units
+ macAddress: string;
+ serviceCount?: number; // Cumulative service counter
+ totalCount?: number; // Total stitches sewn by machine
+}
+
+export interface PatternInfo {
+ totalStitches: number;
+ totalTime: number; // seconds
+ speed: number; // stitches per minute
+ boundLeft: number;
+ boundTop: number;
+ boundRight: number;
+ boundBottom: number;
+}
+
+export interface SewingProgress {
+ currentStitch: number;
+ currentTime: number; // seconds
+ stopTime: number;
+ positionX: number; // in 0.1mm units
+ positionY: number; // in 0.1mm units
+}
+
+export interface PenStitch {
+ x: number;
+ y: number;
+ flags: number;
+ isJump: boolean;
+}
+
+export interface PenColorBlock {
+ startStitch: number;
+ endStitch: number;
+ colorIndex: number;
+}
+
+export interface PenData {
+ stitches: PenStitch[];
+ colorBlocks: PenColorBlock[];
+ totalStitches: number;
+ colorCount: number;
+ bounds: {
+ minX: number;
+ maxX: number;
+ minY: number;
+ maxY: number;
+ };
+}
diff --git a/src/utils/cssVariables.ts b/src/utils/cssVariables.ts
index 512cd2e..efe0143 100644
--- a/src/utils/cssVariables.ts
+++ b/src/utils/cssVariables.ts
@@ -11,9 +11,9 @@ export function getCSSVariable(name: string): string {
* Canvas color helpers
*/
export const canvasColors = {
- grid: () => getCSSVariable('--color-canvas-grid'),
- origin: () => getCSSVariable('--color-canvas-origin'),
- hoop: () => getCSSVariable('--color-canvas-hoop'),
- bounds: () => getCSSVariable('--color-canvas-bounds'),
- position: () => getCSSVariable('--color-canvas-position'),
+ grid: () => getCSSVariable("--color-canvas-grid"),
+ origin: () => getCSSVariable("--color-canvas-origin"),
+ hoop: () => getCSSVariable("--color-canvas-hoop"),
+ bounds: () => getCSSVariable("--color-canvas-bounds"),
+ position: () => getCSSVariable("--color-canvas-position"),
};
diff --git a/src/utils/konvaRenderers.ts b/src/utils/konvaRenderers.ts
index 09fd935..5b919e5 100644
--- a/src/utils/konvaRenderers.ts
+++ b/src/utils/konvaRenderers.ts
@@ -1,8 +1,8 @@
-import Konva from 'konva';
-import type { PesPatternData } from '../formats/import/pesImporter';
-import { getThreadColor } from '../formats/import/pesImporter';
-import type { MachineInfo } from '../types/machine';
-import { MOVE } from '../formats/import/constants';
+import Konva from "konva";
+import type { PesPatternData } from "../formats/import/pesImporter";
+import { getThreadColor } from "../formats/import/pesImporter";
+import type { MachineInfo } from "../types/machine";
+import { MOVE } from "../formats/import/constants";
/**
* Renders a grid with specified spacing
@@ -11,9 +11,9 @@ export function renderGrid(
layer: Konva.Layer,
gridSize: number,
bounds: { minX: number; maxX: number; minY: number; maxY: number },
- machineInfo: MachineInfo | null
+ machineInfo: MachineInfo | null,
): void {
- const gridGroup = new Konva.Group({ name: 'grid' });
+ const gridGroup = new Konva.Group({ name: "grid" });
// Determine grid bounds based on hoop or pattern
const gridMinX = machineInfo ? -machineInfo.maxWidth / 2 : bounds.minX;
@@ -22,20 +22,28 @@ export function renderGrid(
const gridMaxY = machineInfo ? machineInfo.maxHeight / 2 : bounds.maxY;
// Vertical lines
- for (let x = Math.floor(gridMinX / gridSize) * gridSize; x <= gridMaxX; x += gridSize) {
+ for (
+ let x = Math.floor(gridMinX / gridSize) * gridSize;
+ x <= gridMaxX;
+ x += gridSize
+ ) {
const line = new Konva.Line({
points: [x, gridMinY, x, gridMaxY],
- stroke: '#e0e0e0',
+ stroke: "#e0e0e0",
strokeWidth: 1,
});
gridGroup.add(line);
}
// Horizontal lines
- for (let y = Math.floor(gridMinY / gridSize) * gridSize; y <= gridMaxY; y += gridSize) {
+ for (
+ let y = Math.floor(gridMinY / gridSize) * gridSize;
+ y <= gridMaxY;
+ y += gridSize
+ ) {
const line = new Konva.Line({
points: [gridMinX, y, gridMaxX, y],
- stroke: '#e0e0e0',
+ stroke: "#e0e0e0",
strokeWidth: 1,
});
gridGroup.add(line);
@@ -48,19 +56,19 @@ export function renderGrid(
* Renders the origin crosshair at (0,0)
*/
export function renderOrigin(layer: Konva.Layer): void {
- const originGroup = new Konva.Group({ name: 'origin' });
+ const originGroup = new Konva.Group({ name: "origin" });
// Horizontal line
const hLine = new Konva.Line({
points: [-10, 0, 10, 0],
- stroke: '#888',
+ stroke: "#888",
strokeWidth: 2,
});
// Vertical line
const vLine = new Konva.Line({
points: [0, -10, 0, 10],
- stroke: '#888',
+ stroke: "#888",
strokeWidth: 2,
});
@@ -72,7 +80,7 @@ export function renderOrigin(layer: Konva.Layer): void {
* Renders the hoop boundary and label
*/
export function renderHoop(layer: Konva.Layer, machineInfo: MachineInfo): void {
- const hoopGroup = new Konva.Group({ name: 'hoop' });
+ const hoopGroup = new Konva.Group({ name: "hoop" });
const hoopWidth = machineInfo.maxWidth;
const hoopHeight = machineInfo.maxHeight;
@@ -87,7 +95,7 @@ export function renderHoop(layer: Konva.Layer, machineInfo: MachineInfo): void {
y: hoopTop,
width: hoopWidth,
height: hoopHeight,
- stroke: '#2196F3',
+ stroke: "#2196F3",
strokeWidth: 3,
dash: [10, 5],
});
@@ -98,9 +106,9 @@ export function renderHoop(layer: Konva.Layer, machineInfo: MachineInfo): void {
y: hoopTop + 10,
text: `Hoop: ${(hoopWidth / 10).toFixed(0)} x ${(hoopHeight / 10).toFixed(0)} mm`,
fontSize: 14,
- fontFamily: 'sans-serif',
- fontStyle: 'bold',
- fill: '#2196F3',
+ fontFamily: "sans-serif",
+ fontStyle: "bold",
+ fill: "#2196F3",
});
hoopGroup.add(rect, label);
@@ -114,9 +122,9 @@ export function renderStitches(
container: Konva.Layer | Konva.Group,
stitches: number[][],
pesData: PesPatternData,
- currentStitchIndex: number
+ currentStitchIndex: number,
): void {
- const stitchesGroup = new Konva.Group({ name: 'stitches' });
+ const stitchesGroup = new Konva.Group({ name: "stitches" });
// Group stitches by color, completion status, and type (stitch vs jump)
interface StitchGroup {
@@ -164,8 +172,8 @@ export function renderStitches(
points: group.points,
stroke: group.color,
strokeWidth: 1.0,
- lineCap: 'round',
- lineJoin: 'round',
+ lineCap: "round",
+ lineJoin: "round",
dash: [5, 5],
opacity: group.completed ? 0.6 : 0.25,
});
@@ -176,8 +184,8 @@ export function renderStitches(
points: group.points,
stroke: group.color,
strokeWidth: 1.5,
- lineCap: 'round',
- lineJoin: 'round',
+ lineCap: "round",
+ lineJoin: "round",
opacity: group.completed ? 1.0 : 0.3,
});
stitchesGroup.add(line);
@@ -192,7 +200,7 @@ export function renderStitches(
*/
export function renderPatternBounds(
container: Konva.Layer | Konva.Group,
- bounds: { minX: number; maxX: number; minY: number; maxY: number }
+ bounds: { minX: number; maxX: number; minY: number; maxY: number },
): void {
const { minX, maxX, minY, maxY } = bounds;
const patternWidth = maxX - minX;
@@ -203,7 +211,7 @@ export function renderPatternBounds(
y: minY,
width: patternWidth,
height: patternHeight,
- stroke: '#ff0000',
+ stroke: "#ff0000",
strokeWidth: 2,
dash: [5, 5],
});
@@ -217,47 +225,47 @@ export function renderPatternBounds(
export function renderCurrentPosition(
container: Konva.Layer | Konva.Group,
currentStitchIndex: number,
- stitches: number[][]
+ stitches: number[][],
): void {
if (currentStitchIndex <= 0 || currentStitchIndex >= stitches.length) return;
const stitch = stitches[currentStitchIndex];
const [x, y] = stitch;
- const posGroup = new Konva.Group({ name: 'currentPosition' });
+ const posGroup = new Konva.Group({ name: "currentPosition" });
// Circle with fill
const circle = new Konva.Circle({
x,
y,
radius: 8,
- fill: 'rgba(255, 0, 0, 0.3)',
- stroke: '#ff0000',
+ fill: "rgba(255, 0, 0, 0.3)",
+ stroke: "#ff0000",
strokeWidth: 3,
});
// Crosshair lines
const hLine1 = new Konva.Line({
points: [x - 12, y, x - 3, y],
- stroke: '#ff0000',
+ stroke: "#ff0000",
strokeWidth: 2,
});
const hLine2 = new Konva.Line({
points: [x + 12, y, x + 3, y],
- stroke: '#ff0000',
+ stroke: "#ff0000",
strokeWidth: 2,
});
const vLine1 = new Konva.Line({
points: [x, y - 12, x, y - 3],
- stroke: '#ff0000',
+ stroke: "#ff0000",
strokeWidth: 2,
});
const vLine2 = new Konva.Line({
points: [x, y + 12, x, y + 3],
- stroke: '#ff0000',
+ stroke: "#ff0000",
strokeWidth: 2,
});
@@ -270,9 +278,9 @@ export function renderCurrentPosition(
*/
export function renderLegend(
layer: Konva.Layer,
- pesData: PesPatternData
+ pesData: PesPatternData,
): void {
- const legendGroup = new Konva.Group({ name: 'legend' });
+ const legendGroup = new Konva.Group({ name: "legend" });
// Semi-transparent background for better readability
const bgPadding = 8;
@@ -284,9 +292,9 @@ export function renderLegend(
y: 10,
width: 100,
height: legendHeight,
- fill: 'rgba(255, 255, 255, 0.9)',
+ fill: "rgba(255, 255, 255, 0.9)",
cornerRadius: 4,
- shadowColor: 'rgba(0, 0, 0, 0.2)',
+ shadowColor: "rgba(0, 0, 0, 0.2)",
shadowBlur: 4,
shadowOffset: { x: 0, y: 2 },
});
@@ -305,7 +313,7 @@ export function renderLegend(
width: 20,
height: 20,
fill: color,
- stroke: '#000',
+ stroke: "#000",
strokeWidth: 1,
});
@@ -315,8 +323,8 @@ export function renderLegend(
y: legendY + 5,
text: `Thread ${i + 1}`,
fontSize: 12,
- fontFamily: 'sans-serif',
- fill: '#000',
+ fontFamily: "sans-serif",
+ fill: "#000",
});
legendGroup.add(swatch, label);
@@ -334,7 +342,7 @@ export function renderDimensions(
patternWidth: number,
patternHeight: number,
stageWidth: number,
- stageHeight: number
+ stageHeight: number,
): void {
const dimensionText = `${(patternWidth / 10).toFixed(1)} x ${(patternHeight / 10).toFixed(1)} mm`;
@@ -348,9 +356,9 @@ export function renderDimensions(
y: stageHeight - textHeight - padding - 80, // Above zoom controls
width: textWidth,
height: textHeight,
- fill: 'rgba(255, 255, 255, 0.9)',
+ fill: "rgba(255, 255, 255, 0.9)",
cornerRadius: 4,
- shadowColor: 'rgba(0, 0, 0, 0.2)',
+ shadowColor: "rgba(0, 0, 0, 0.2)",
shadowBlur: 4,
shadowOffset: { x: 0, y: 2 },
});
@@ -362,10 +370,10 @@ export function renderDimensions(
height: textHeight,
text: dimensionText,
fontSize: 14,
- fontFamily: 'sans-serif',
- fill: '#000',
- align: 'center',
- verticalAlign: 'middle',
+ fontFamily: "sans-serif",
+ fill: "#000",
+ align: "center",
+ verticalAlign: "middle",
});
layer.add(background, text);
@@ -379,7 +387,7 @@ export function calculateInitialScale(
stageHeight: number,
viewWidth: number,
viewHeight: number,
- padding: number = 40
+ padding: number = 40,
): number {
const scaleX = (stageWidth - 2 * padding) / viewWidth;
const scaleY = (stageHeight - 2 * padding) / viewHeight;
diff --git a/src/utils/machineStateHelpers.ts b/src/utils/machineStateHelpers.ts
index 558785b..8548bc6 100644
--- a/src/utils/machineStateHelpers.ts
+++ b/src/utils/machineStateHelpers.ts
@@ -1,188 +1,207 @@
-import { MachineStatus } from '../types/machine';
-
-/**
- * Machine state categories for safety logic
- */
-export const MachineStateCategory = {
- IDLE: 'idle',
- ACTIVE: 'active',
- WAITING: 'waiting',
- COMPLETE: 'complete',
- INTERRUPTED: 'interrupted',
- ERROR: 'error',
-} as const;
-
-export type MachineStateCategoryType = typeof MachineStateCategory[keyof typeof MachineStateCategory];
-
-/**
- * Categorize a machine status into a semantic safety category
- */
-export function getMachineStateCategory(status: MachineStatus): MachineStateCategoryType {
- switch (status) {
- // IDLE states - safe to perform any action
- case MachineStatus.IDLE:
- case MachineStatus.SEWING_WAIT:
- case MachineStatus.Initial:
- case MachineStatus.LowerThread:
- return MachineStateCategory.IDLE;
-
- // ACTIVE states - operation in progress, dangerous to interrupt
- case MachineStatus.SEWING:
- case MachineStatus.MASK_TRACING:
- case MachineStatus.SEWING_DATA_RECEIVE:
- case MachineStatus.HOOP_AVOIDANCEING:
- return MachineStateCategory.ACTIVE;
-
- // WAITING states - waiting for user/machine action
- case MachineStatus.COLOR_CHANGE_WAIT:
- case MachineStatus.MASK_TRACE_LOCK_WAIT:
- case MachineStatus.HOOP_AVOIDANCE:
- return MachineStateCategory.WAITING;
-
- // COMPLETE states - operation finished
- case MachineStatus.SEWING_COMPLETE:
- case MachineStatus.MASK_TRACE_COMPLETE:
- case MachineStatus.RL_RECEIVED:
- return MachineStateCategory.COMPLETE;
-
- // INTERRUPTED states - operation paused/stopped
- case MachineStatus.PAUSE:
- case MachineStatus.STOP:
- case MachineStatus.SEWING_INTERRUPTION:
- return MachineStateCategory.INTERRUPTED;
-
- // ERROR/UNKNOWN states
- case MachineStatus.None:
- case MachineStatus.TryConnecting:
- case MachineStatus.RL_RECEIVING:
- default:
- return MachineStateCategory.ERROR;
- }
-}
-
-/**
- * Determines if the pattern can be safely deleted in the current state.
- * Prevents deletion during active operations (SEWING, MASK_TRACING, etc.)
- */
-export function canDeletePattern(status: MachineStatus): boolean {
- const category = getMachineStateCategory(status);
- // Can delete in IDLE, WAITING, or COMPLETE states, never during ACTIVE operations
- return category === MachineStateCategory.IDLE ||
- category === MachineStateCategory.WAITING ||
- category === MachineStateCategory.COMPLETE;
-}
-
-/**
- * Determines if a pattern can be safely uploaded in the current state.
- * Only allow uploads when machine is idle or in a complete state.
- */
-export function canUploadPattern(status: MachineStatus): boolean {
- const category = getMachineStateCategory(status);
- // Can upload in IDLE or COMPLETE states (includes MASK_TRACE_COMPLETE)
- return category === MachineStateCategory.IDLE ||
- category === MachineStateCategory.COMPLETE;
-}
-
-/**
- * Determines if sewing can be started in the current state.
- * Allows starting from ready state or resuming from interrupted states.
- */
-export function canStartSewing(status: MachineStatus): boolean {
- // Only in specific ready states
- return status === MachineStatus.SEWING_WAIT ||
- status === MachineStatus.MASK_TRACE_COMPLETE ||
- status === MachineStatus.PAUSE ||
- status === MachineStatus.STOP ||
- status === MachineStatus.SEWING_INTERRUPTION;
-}
-
-/**
- * Determines if mask trace can be started in the current state.
- */
-export function canStartMaskTrace(status: MachineStatus): boolean {
- // Can start mask trace when IDLE (after upload), SEWING_WAIT, or after previous trace
- return status === MachineStatus.IDLE ||
- status === MachineStatus.SEWING_WAIT ||
- status === MachineStatus.MASK_TRACE_COMPLETE;
-}
-
-/**
- * Determines if sewing can be resumed in the current state.
- * Only for interrupted operations (PAUSE, STOP, SEWING_INTERRUPTION).
- */
-export function canResumeSewing(status: MachineStatus): boolean {
- // Only in interrupted states
- const category = getMachineStateCategory(status);
- return category === MachineStateCategory.INTERRUPTED;
-}
-
-/**
- * Determines if disconnect should show a confirmation dialog.
- * Confirms if disconnecting during active operation or while waiting.
- */
-export function shouldConfirmDisconnect(status: MachineStatus): boolean {
- const category = getMachineStateCategory(status);
- // Confirm if disconnecting during active operation or waiting for action
- return category === MachineStateCategory.ACTIVE ||
- category === MachineStateCategory.WAITING;
-}
-
-/**
- * Visual information for a machine state
- */
-export interface StateVisualInfo {
- color: string;
- iconName: 'ready' | 'active' | 'waiting' | 'complete' | 'interrupted' | 'error';
- label: string;
- description: string;
-}
-
-/**
- * Get visual styling information for a machine state.
- * Returns color, icon, label, and description for UI display.
- */
-export function getStateVisualInfo(status: MachineStatus): StateVisualInfo {
- const category = getMachineStateCategory(status);
-
- // Map state category to visual properties
- const visualMap: Record<MachineStateCategoryType, StateVisualInfo> = {
- [MachineStateCategory.IDLE]: {
- color: 'info',
- iconName: 'ready',
- label: 'Ready',
- description: 'Machine is idle and ready for operations'
- },
- [MachineStateCategory.ACTIVE]: {
- color: 'warning',
- iconName: 'active',
- label: 'Active',
- description: 'Operation in progress - do not interrupt'
- },
- [MachineStateCategory.WAITING]: {
- color: 'warning',
- iconName: 'waiting',
- label: 'Waiting',
- description: 'Waiting for user or machine action'
- },
- [MachineStateCategory.COMPLETE]: {
- color: 'success',
- iconName: 'complete',
- label: 'Complete',
- description: 'Operation completed successfully'
- },
- [MachineStateCategory.INTERRUPTED]: {
- color: 'danger',
- iconName: 'interrupted',
- label: 'Interrupted',
- description: 'Operation paused or stopped'
- },
- [MachineStateCategory.ERROR]: {
- color: 'danger',
- iconName: 'error',
- label: 'Error',
- description: 'Machine in error or unknown state'
- }
- };
-
- return visualMap[category];
-}
+import { MachineStatus } from "../types/machine";
+
+/**
+ * Machine state categories for safety logic
+ */
+export const MachineStateCategory = {
+ IDLE: "idle",
+ ACTIVE: "active",
+ WAITING: "waiting",
+ COMPLETE: "complete",
+ INTERRUPTED: "interrupted",
+ ERROR: "error",
+} as const;
+
+export type MachineStateCategoryType =
+ (typeof MachineStateCategory)[keyof typeof MachineStateCategory];
+
+/**
+ * Categorize a machine status into a semantic safety category
+ */
+export function getMachineStateCategory(
+ status: MachineStatus,
+): MachineStateCategoryType {
+ switch (status) {
+ // IDLE states - safe to perform any action
+ case MachineStatus.IDLE:
+ case MachineStatus.SEWING_WAIT:
+ case MachineStatus.Initial:
+ case MachineStatus.LowerThread:
+ return MachineStateCategory.IDLE;
+
+ // ACTIVE states - operation in progress, dangerous to interrupt
+ case MachineStatus.SEWING:
+ case MachineStatus.MASK_TRACING:
+ case MachineStatus.SEWING_DATA_RECEIVE:
+ case MachineStatus.HOOP_AVOIDANCEING:
+ return MachineStateCategory.ACTIVE;
+
+ // WAITING states - waiting for user/machine action
+ case MachineStatus.COLOR_CHANGE_WAIT:
+ case MachineStatus.MASK_TRACE_LOCK_WAIT:
+ case MachineStatus.HOOP_AVOIDANCE:
+ return MachineStateCategory.WAITING;
+
+ // COMPLETE states - operation finished
+ case MachineStatus.SEWING_COMPLETE:
+ case MachineStatus.MASK_TRACE_COMPLETE:
+ case MachineStatus.RL_RECEIVED:
+ return MachineStateCategory.COMPLETE;
+
+ // INTERRUPTED states - operation paused/stopped
+ case MachineStatus.PAUSE:
+ case MachineStatus.STOP:
+ case MachineStatus.SEWING_INTERRUPTION:
+ return MachineStateCategory.INTERRUPTED;
+
+ // ERROR/UNKNOWN states
+ case MachineStatus.None:
+ case MachineStatus.TryConnecting:
+ case MachineStatus.RL_RECEIVING:
+ default:
+ return MachineStateCategory.ERROR;
+ }
+}
+
+/**
+ * Determines if the pattern can be safely deleted in the current state.
+ * Prevents deletion during active operations (SEWING, MASK_TRACING, etc.)
+ */
+export function canDeletePattern(status: MachineStatus): boolean {
+ const category = getMachineStateCategory(status);
+ // Can delete in IDLE, WAITING, or COMPLETE states, never during ACTIVE operations
+ return (
+ category === MachineStateCategory.IDLE ||
+ category === MachineStateCategory.WAITING ||
+ category === MachineStateCategory.COMPLETE
+ );
+}
+
+/**
+ * Determines if a pattern can be safely uploaded in the current state.
+ * Only allow uploads when machine is idle or in a complete state.
+ */
+export function canUploadPattern(status: MachineStatus): boolean {
+ const category = getMachineStateCategory(status);
+ // Can upload in IDLE or COMPLETE states (includes MASK_TRACE_COMPLETE)
+ return (
+ category === MachineStateCategory.IDLE ||
+ category === MachineStateCategory.COMPLETE
+ );
+}
+
+/**
+ * Determines if sewing can be started in the current state.
+ * Allows starting from ready state or resuming from interrupted states.
+ */
+export function canStartSewing(status: MachineStatus): boolean {
+ // Only in specific ready states
+ return (
+ status === MachineStatus.SEWING_WAIT ||
+ status === MachineStatus.MASK_TRACE_COMPLETE ||
+ status === MachineStatus.PAUSE ||
+ status === MachineStatus.STOP ||
+ status === MachineStatus.SEWING_INTERRUPTION
+ );
+}
+
+/**
+ * Determines if mask trace can be started in the current state.
+ */
+export function canStartMaskTrace(status: MachineStatus): boolean {
+ // Can start mask trace when IDLE (after upload), SEWING_WAIT, or after previous trace
+ return (
+ status === MachineStatus.IDLE ||
+ status === MachineStatus.SEWING_WAIT ||
+ status === MachineStatus.MASK_TRACE_COMPLETE
+ );
+}
+
+/**
+ * Determines if sewing can be resumed in the current state.
+ * Only for interrupted operations (PAUSE, STOP, SEWING_INTERRUPTION).
+ */
+export function canResumeSewing(status: MachineStatus): boolean {
+ // Only in interrupted states
+ const category = getMachineStateCategory(status);
+ return category === MachineStateCategory.INTERRUPTED;
+}
+
+/**
+ * Determines if disconnect should show a confirmation dialog.
+ * Confirms if disconnecting during active operation or while waiting.
+ */
+export function shouldConfirmDisconnect(status: MachineStatus): boolean {
+ const category = getMachineStateCategory(status);
+ // Confirm if disconnecting during active operation or waiting for action
+ return (
+ category === MachineStateCategory.ACTIVE ||
+ category === MachineStateCategory.WAITING
+ );
+}
+
+/**
+ * Visual information for a machine state
+ */
+export interface StateVisualInfo {
+ color: string;
+ iconName:
+ | "ready"
+ | "active"
+ | "waiting"
+ | "complete"
+ | "interrupted"
+ | "error";
+ label: string;
+ description: string;
+}
+
+/**
+ * Get visual styling information for a machine state.
+ * Returns color, icon, label, and description for UI display.
+ */
+export function getStateVisualInfo(status: MachineStatus): StateVisualInfo {
+ const category = getMachineStateCategory(status);
+
+ // Map state category to visual properties
+ const visualMap: Record<MachineStateCategoryType, StateVisualInfo> = {
+ [MachineStateCategory.IDLE]: {
+ color: "info",
+ iconName: "ready",
+ label: "Ready",
+ description: "Machine is idle and ready for operations",
+ },
+ [MachineStateCategory.ACTIVE]: {
+ color: "warning",
+ iconName: "active",
+ label: "Active",
+ description: "Operation in progress - do not interrupt",
+ },
+ [MachineStateCategory.WAITING]: {
+ color: "warning",
+ iconName: "waiting",
+ label: "Waiting",
+ description: "Waiting for user or machine action",
+ },
+ [MachineStateCategory.COMPLETE]: {
+ color: "success",
+ iconName: "complete",
+ label: "Complete",
+ description: "Operation completed successfully",
+ },
+ [MachineStateCategory.INTERRUPTED]: {
+ color: "danger",
+ iconName: "interrupted",
+ label: "Interrupted",
+ description: "Operation paused or stopped",
+ },
+ [MachineStateCategory.ERROR]: {
+ color: "danger",
+ iconName: "error",
+ label: "Error",
+ description: "Machine in error or unknown state",
+ },
+ };
+
+ return visualMap[category];
+}
diff --git a/src/utils/timeCalculation.ts b/src/utils/timeCalculation.ts
index e85b8e4..205fc0d 100644
--- a/src/utils/timeCalculation.ts
+++ b/src/utils/timeCalculation.ts
@@ -20,7 +20,7 @@ export function convertStitchesToMinutes(stitchCount: number): number {
*/
export function calculatePatternTime(
colorBlocks: Array<{ stitchCount: number }>,
- currentStitch: number
+ currentStitch: number,
): {
totalMinutes: number;
elapsedMinutes: number;
@@ -44,7 +44,8 @@ export function calculatePatternTime(
break;
} else {
// We're partway through this block
- const stitchesInBlock = currentStitch - (cumulativeStitches - block.stitchCount);
+ const stitchesInBlock =
+ currentStitch - (cumulativeStitches - block.stitchCount);
elapsedMinutes += convertStitchesToMinutes(stitchesInBlock);
break;
}
@@ -63,5 +64,5 @@ export function calculatePatternTime(
export function formatMinutes(minutes: number): string {
const mins = Math.floor(minutes);
const secs = Math.round((minutes - mins) * 60);
- return `${mins}:${String(secs).padStart(2, '0')}`;
+ return `${mins}:${String(secs).padStart(2, "0")}`;
}
diff --git a/vitest.config.ts b/vitest.config.ts
index a6de6dc..43f4055 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,9 +1,9 @@
-import { defineConfig } from 'vitest/config';
+import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
- environment: 'node',
- include: ['src/**/*.{test,spec}.{js,ts}'],
+ environment: "node",
+ include: ["src/**/*.{test,spec}.{js,ts}"],
},
});
Served by rngit 1.3.3 - Generated in 0.4s